10.April 2026
This commit is contained in:
parent
a00c42e770
commit
f58c709945
208 changed files with 19280 additions and 2914 deletions
|
|
@ -1,78 +1,45 @@
|
||||||
{
|
{
|
||||||
"name": "Mivita Care (Dev Container)",
|
"name": "Mivita Care (Dev Container)",
|
||||||
// 1. DIES IST DER WICHTIGSTE TEIL:
|
|
||||||
// Wir verwenden Docker Compose für alle Services
|
|
||||||
"dockerComposeFile": [
|
"dockerComposeFile": [
|
||||||
"../docker-compose.yml"
|
"../docker-compose.yml"
|
||||||
],
|
],
|
||||||
"service": "laravel.test",
|
"service": "laravel.test",
|
||||||
// 3. WIR DEFINIEREN DEN ARBEITSBEREICH:
|
|
||||||
// Das ist der Pfad, in dem Ihr Code *innerhalb* des Containers liegt.
|
|
||||||
"workspaceFolder": "/var/www/html",
|
"workspaceFolder": "/var/www/html",
|
||||||
// 4. WIR LEGEN DEN BENUTZER FEST:
|
|
||||||
// Laravel Sail führt Befehle standardmäßig als 'sail'-Benutzer aus, um Berechtigungsprobleme zu vermeiden.
|
|
||||||
"remoteUser": "sail",
|
"remoteUser": "sail",
|
||||||
// 5. ZUSÄTZLICHE ENTWICKLER-TOOLS (FEATURES):
|
|
||||||
// Features werden über postCreateCommand installiert um Kompatibilitätsprobleme zu vermeiden
|
|
||||||
"features": {},
|
"features": {},
|
||||||
// 6. BEFEHLE NACH DEM ERSTELLEN:
|
|
||||||
// Installiert nur die Tools die ohne Root-Rechte funktionieren
|
|
||||||
//"postCreateCommand": "composer install --no-interaction --prefer-dist --optimize-autoloader",
|
|
||||||
// 7. EDITOR-ANPASSUNGEN (Optional, aber sehr empfohlen):
|
|
||||||
"customizations": {
|
"customizations": {
|
||||||
"vscode": {
|
"vscode": {
|
||||||
"extensions": [
|
"extensions": [
|
||||||
"bmewburn.vscode-intelephense-client",
|
"bmewburn.vscode-intelephense-client",
|
||||||
"onecentlin.laravel-blade",
|
"onecentlin.laravel-blade",
|
||||||
"shufo.vscode-blade-formatter",
|
"shufo.vscode-blade-formatter",
|
||||||
"bradlc.vscode-tailwindcss"
|
"bradlc.vscode-tailwindcss",
|
||||||
|
"Anthropic.claude-code",
|
||||||
|
"onecentlin.laravel-extension-pack"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// 8. ZU STARTENDE DIENSTE:
|
// WICHTIG: Hier stehen jetzt nur noch die Dienste, die es im Projekt wirklich gibt!
|
||||||
// Legt fest, welche Dienste aus der docker-compose.yml gestartet werden sollen.
|
|
||||||
"runServices": [
|
"runServices": [
|
||||||
"laravel.test",
|
"laravel.test",
|
||||||
"mysql",
|
"horizon"
|
||||||
"redis",
|
|
||||||
"mailpit"
|
|
||||||
],
|
],
|
||||||
// 9. ZUSÄTZLICHE KONFIGURATION:
|
|
||||||
// Umgebungsvariablen für den DevContainer
|
|
||||||
"containerEnv": {
|
"containerEnv": {
|
||||||
"WWWUSER": "501",
|
"WWWUSER": "501",
|
||||||
"WWWGROUP": "20",
|
"WWWGROUP": "20",
|
||||||
"LARAVEL_SAIL": "1"
|
"LARAVEL_SAIL": "1"
|
||||||
},
|
},
|
||||||
// 10. MOUNT-KONFIGURATION:
|
|
||||||
// Stellt sicher, dass der Code korrekt gemountet wird
|
|
||||||
"mounts": [
|
"mounts": [
|
||||||
"source=${localWorkspaceFolder},target=/var/www/html,type=bind,consistency=cached"
|
"source=${localWorkspaceFolder},target=/var/www/html,type=bind,consistency=cached"
|
||||||
],
|
],
|
||||||
// 11. FORWARD PORTS:
|
// WICHTIG: Nur noch der Vite-Port muss weitergeleitet werden, den Rest macht das Mutterschiff.
|
||||||
// Ports die automatisch weitergeleitet werden sollen
|
|
||||||
"forwardPorts": [
|
"forwardPorts": [
|
||||||
5173,
|
5173
|
||||||
33061,
|
|
||||||
6380,
|
|
||||||
8025
|
|
||||||
],
|
],
|
||||||
"portsAttributes": {
|
"portsAttributes": {
|
||||||
"5173": {
|
"5173": {
|
||||||
"label": "Vite Dev Server",
|
"label": "Vite Dev Server",
|
||||||
"onAutoForward": "notify"
|
"onAutoForward": "notify"
|
||||||
},
|
|
||||||
"33061": {
|
|
||||||
"label": "MySQL",
|
|
||||||
"onAutoForward": "silent"
|
|
||||||
},
|
|
||||||
"6380": {
|
|
||||||
"label": "Redis",
|
|
||||||
"onAutoForward": "silent"
|
|
||||||
},
|
|
||||||
"8025": {
|
|
||||||
"label": "Mailpit Dashboard",
|
|
||||||
"onAutoForward": "notify"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
6
.env
6
.env
|
|
@ -42,13 +42,13 @@ APP_PHP_VERSION=8.2
|
||||||
|
|
||||||
LOG_CHANNEL=stack
|
LOG_CHANNEL=stack
|
||||||
|
|
||||||
|
|
||||||
DB_CONNECTION=mysql
|
DB_CONNECTION=mysql
|
||||||
DB_HOST=mysql
|
DB_HOST=global-mysql
|
||||||
DB_PORT=3306
|
DB_PORT=3306
|
||||||
DB_DATABASE=mivita
|
DB_DATABASE=mivita
|
||||||
DB_USERNAME=sail
|
DB_USERNAME=root
|
||||||
DB_PASSWORD=password
|
DB_PASSWORD=password
|
||||||
FORWARD_DB_PORT=33061
|
|
||||||
MYSQL_EXTRA_OPTIONS=
|
MYSQL_EXTRA_OPTIONS=
|
||||||
|
|
||||||
#DB_HOST=192.168.1.8
|
#DB_HOST=192.168.1.8
|
||||||
|
|
|
||||||
169
app/Console/Commands/AboStoreChartSnapshots.php
Normal file
169
app/Console/Commands/AboStoreChartSnapshots.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,9 @@ use App\Cron\UserPaymentCredits;
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
use App\Models\UserBusiness;
|
use App\Models\UserBusiness;
|
||||||
use App\Models\UserBusinessStructure;
|
use App\Models\UserBusinessStructure;
|
||||||
|
use App\Models\UserSalesVolume;
|
||||||
|
use App\Services\BusinessPlan\SalesPointsVolume;
|
||||||
|
use App\User;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
class BusinessStoreOptimized extends Command
|
class BusinessStoreOptimized extends Command
|
||||||
|
|
@ -163,6 +166,10 @@ class BusinessStoreOptimized extends Command
|
||||||
$this->userLevelUpdate();
|
$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
|
// Auskommentierte Prozesse bleiben inaktiv
|
||||||
// $this->userCreatePaymentCreditsPDF();
|
// $this->userCreatePaymentCreditsPDF();
|
||||||
// $this->storeBusinessStructureUsersDetailPeriod(1, 6);
|
// $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)
|
private function logExecutionTime($message)
|
||||||
{
|
{
|
||||||
$diff = microtime(true) - $this->timeStart;
|
$diff = microtime(true) - $this->timeStart;
|
||||||
|
|
|
||||||
205
app/Console/Commands/IncentiveCalculate.php
Normal file
205
app/Console/Commands/IncentiveCalculate.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
196
app/Console/Commands/IncentiveDebugTrackPartner.php
Normal file
196
app/Console/Commands/IncentiveDebugTrackPartner.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
241
app/Console/Commands/IncentiveDebugTrackSalesVolume.php
Normal file
241
app/Console/Commands/IncentiveDebugTrackSalesVolume.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
419
app/Console/Commands/PayoneFailedPaypalReport.php
Normal file
419
app/Console/Commands/PayoneFailedPaypalReport.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
220
app/Console/Commands/RepairMissingAboFromOrders.php
Normal file
220
app/Console/Commands/RepairMissingAboFromOrders.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
129
app/Console/Commands/RepairMissingInvoices.php
Normal file
129
app/Console/Commands/RepairMissingInvoices.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
297
app/Console/Commands/RetryFailedPaypalAbos.php
Normal file
297
app/Console/Commands/RetryFailedPaypalAbos.php
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,13 +2,12 @@
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use Carbon\Carbon;
|
|
||||||
use App\Models\UserAbo;
|
|
||||||
use App\Cron\UserMakeOrder;
|
use App\Cron\UserMakeOrder;
|
||||||
use App\Services\AboHelper;
|
use App\Models\UserAbo;
|
||||||
use App\Models\UserAboOrder;
|
use App\Models\UserAboOrder;
|
||||||
|
use App\Services\AboHelper;
|
||||||
|
use Carbon\Carbon;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class TestUserMakeAboOrder extends Command
|
class TestUserMakeAboOrder extends Command
|
||||||
|
|
@ -65,6 +64,7 @@ class TestUserMakeAboOrder extends Command
|
||||||
$userAbo = UserAbo::find($aboId);
|
$userAbo = UserAbo::find($aboId);
|
||||||
if (! $userAbo) {
|
if (! $userAbo) {
|
||||||
$this->error("Abo mit ID {$aboId} nicht gefunden!");
|
$this->error("Abo mit ID {$aboId} nicht gefunden!");
|
||||||
|
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -82,6 +82,7 @@ class TestUserMakeAboOrder extends Command
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->error('Fehler beim Testen: '.$e->getMessage());
|
$this->error('Fehler beim Testen: '.$e->getMessage());
|
||||||
$this->error($e->getTraceAsString());
|
$this->error($e->getTraceAsString());
|
||||||
|
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -144,7 +145,7 @@ class TestUserMakeAboOrder extends Command
|
||||||
if ($shoppingOrder) {
|
if ($shoppingOrder) {
|
||||||
$this->info("✓ Bestellung erfolgreich erstellt: ID {$shoppingOrder->id}");
|
$this->info("✓ Bestellung erfolgreich erstellt: ID {$shoppingOrder->id}");
|
||||||
} else {
|
} else {
|
||||||
$this->error("✗ Bestellung konnte nicht erstellt werden");
|
$this->error('✗ Bestellung konnte nicht erstellt werden');
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
// next_date zurücksetzen falls geändert
|
// next_date zurücksetzen falls geändert
|
||||||
|
|
@ -184,6 +185,7 @@ class TestUserMakeAboOrder extends Command
|
||||||
|
|
||||||
if ($count === 0) {
|
if ($count === 0) {
|
||||||
$this->warn('Keine fälligen Abos gefunden!');
|
$this->warn('Keine fälligen Abos gefunden!');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -276,6 +278,7 @@ class TestUserMakeAboOrder extends Command
|
||||||
|
|
||||||
if (! $userOrder->createShoppingUser()) {
|
if (! $userOrder->createShoppingUser()) {
|
||||||
$this->error('Konnte Shopping-User nicht erstellen');
|
$this->error('Konnte Shopping-User nicht erstellen');
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
$this->info('✓ Shopping-User erstellt');
|
$this->info('✓ Shopping-User erstellt');
|
||||||
|
|
@ -286,6 +289,7 @@ class TestUserMakeAboOrder extends Command
|
||||||
$shoppingOrder->save();
|
$shoppingOrder->save();
|
||||||
if (! $shoppingOrder) {
|
if (! $shoppingOrder) {
|
||||||
$this->error('Konnte Bestellung nicht erstellen');
|
$this->error('Konnte Bestellung nicht erstellen');
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
$this->info("✓ Bestellung erstellt: ID {$shoppingOrder->id}");
|
$this->info("✓ Bestellung erstellt: ID {$shoppingOrder->id}");
|
||||||
|
|
@ -293,6 +297,7 @@ class TestUserMakeAboOrder extends Command
|
||||||
if ($dryRun) {
|
if ($dryRun) {
|
||||||
$this->info('[DRY-RUN] Zahlung würde durchgeführt');
|
$this->info('[DRY-RUN] Zahlung würde durchgeführt');
|
||||||
$this->info('[DRY-RUN] Abo würde aktualisiert');
|
$this->info('[DRY-RUN] Abo würde aktualisiert');
|
||||||
|
|
||||||
return $shoppingOrder;
|
return $shoppingOrder;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -308,6 +313,7 @@ class TestUserMakeAboOrder extends Command
|
||||||
|
|
||||||
if (! isset($response['status'])) {
|
if (! isset($response['status'])) {
|
||||||
$this->warn('⚠ Kein Status in Zahlungsantwort');
|
$this->warn('⚠ Kein Status in Zahlungsantwort');
|
||||||
|
|
||||||
return $shoppingOrder;
|
return $shoppingOrder;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -356,6 +362,7 @@ class TestUserMakeAboOrder extends Command
|
||||||
'user_abo_id' => $userAbo->id,
|
'user_abo_id' => $userAbo->id,
|
||||||
'shopping_order_id' => $shoppingOrder->id,
|
'shopping_order_id' => $shoppingOrder->id,
|
||||||
'status' => $status,
|
'status' => $status,
|
||||||
|
'paid' => true,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,15 @@
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use Carbon\Carbon;
|
use App\Cron\UserMakeOrder;
|
||||||
use App\Models\Setting;
|
|
||||||
use App\Models\UserAbo;
|
use App\Models\UserAbo;
|
||||||
|
use App\Models\UserAboOrder;
|
||||||
|
use App\Services\AboHelper;
|
||||||
|
use App\Services\Incentive\IncentiveTracker;
|
||||||
use App\Services\MyLog;
|
use App\Services\MyLog;
|
||||||
use App\Services\Payment;
|
use App\Services\Payment;
|
||||||
use App\Cron\UserMakeOrder;
|
use Carbon\Carbon;
|
||||||
use App\Services\AboHelper;
|
|
||||||
use App\Models\UserAboOrder;
|
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class UserMakeAboOrder extends Command
|
class UserMakeAboOrder extends Command
|
||||||
|
|
@ -33,10 +32,13 @@ class UserMakeAboOrder extends Command
|
||||||
protected $description = 'Make Orders from Abos';
|
protected $description = 'Make Orders from Abos';
|
||||||
|
|
||||||
private $timeStart;
|
private $timeStart;
|
||||||
|
|
||||||
private $month;
|
private $month;
|
||||||
|
|
||||||
private $year;
|
private $year;
|
||||||
|
|
||||||
private $sendCreditMail = false;
|
private $sendCreditMail = false;
|
||||||
|
|
||||||
private $sendUpdateMail = false;
|
private $sendUpdateMail = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -70,9 +72,10 @@ class UserMakeAboOrder extends Command
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
\Log::channel('cron')->error('UserMakeAboOrder: Fehler beim Ausführen des Befehls', [
|
\Log::channel('cron')->error('UserMakeAboOrder: Fehler beim Ausführen des Befehls', [
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
'trace' => $e->getTraceAsString()
|
'trace' => $e->getTraceAsString(),
|
||||||
]);
|
]);
|
||||||
$this->error('Fehler beim Ausführen des Befehls: ' . $e->getMessage());
|
$this->error('Fehler beim Ausführen des Befehls: ' . $e->getMessage());
|
||||||
|
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -104,7 +107,7 @@ class UserMakeAboOrder extends Command
|
||||||
foreach ($userAbos as $userAbo) {
|
foreach ($userAbos as $userAbo) {
|
||||||
\Log::channel('abo_order')->info('UserMakeAboOrder: Verarbeite Abo', [
|
\Log::channel('abo_order')->info('UserMakeAboOrder: Verarbeite Abo', [
|
||||||
'abo_id' => $userAbo->id,
|
'abo_id' => $userAbo->id,
|
||||||
'payone_userid' => $userAbo->payone_userid
|
'payone_userid' => $userAbo->payone_userid,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->info("Verarbeite Abo: {$userAbo->id} (PayoneUserid: {$userAbo->payone_userid})");
|
$this->info("Verarbeite Abo: {$userAbo->id} (PayoneUserid: {$userAbo->payone_userid})");
|
||||||
|
|
@ -122,8 +125,9 @@ class UserMakeAboOrder extends Command
|
||||||
|
|
||||||
if (! $lockedAbo) {
|
if (! $lockedAbo) {
|
||||||
\Log::channel('abo_order')->warning('UserMakeAboOrder: Abo wurde bereits verarbeitet oder ist nicht mehr aktiv', [
|
\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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -135,8 +139,9 @@ class UserMakeAboOrder extends Command
|
||||||
if ($existingOrder) {
|
if ($existingOrder) {
|
||||||
\Log::channel('abo_order')->info('UserMakeAboOrder: Abo wurde bereits heute verarbeitet', [
|
\Log::channel('abo_order')->info('UserMakeAboOrder: Abo wurde bereits heute verarbeitet', [
|
||||||
'abo_id' => $lockedAbo->id,
|
'abo_id' => $lockedAbo->id,
|
||||||
'existing_order_id' => $existingOrder->shopping_order_id
|
'existing_order_id' => $existingOrder->shopping_order_id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -146,18 +151,18 @@ class UserMakeAboOrder extends Command
|
||||||
if ($shoppingOrder) {
|
if ($shoppingOrder) {
|
||||||
\Log::channel('abo_order')->info('UserMakeAboOrder: Bestellung erstellt', [
|
\Log::channel('abo_order')->info('UserMakeAboOrder: Bestellung erstellt', [
|
||||||
'abo_id' => $userAbo->id,
|
'abo_id' => $userAbo->id,
|
||||||
'order_id' => $shoppingOrder->id
|
'order_id' => $shoppingOrder->id,
|
||||||
]);
|
]);
|
||||||
$this->info("Bestellung erstellt: {$shoppingOrder->id}");
|
$this->info("Bestellung erstellt: {$shoppingOrder->id}");
|
||||||
} else {
|
} else {
|
||||||
\Log::channel('abo_order')->warning('UserMakeAboOrder: Keine Bestellung erstellt für Abo', ['abo_id' => $userAbo->id]);
|
\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}");
|
$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', [
|
\Log::channel('abo_order')->error('UserMakeAboOrder: Fehler bei der Verarbeitung des Abos', [
|
||||||
'abo_id' => $userAbo->id,
|
'abo_id' => $userAbo->id,
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
'trace' => $e->getTraceAsString()
|
'trace' => $e->getTraceAsString(),
|
||||||
]);
|
]);
|
||||||
$this->error("Fehler bei Abo {$userAbo->id}: " . $e->getMessage());
|
$this->error("Fehler bei Abo {$userAbo->id}: " . $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
@ -182,6 +187,7 @@ class UserMakeAboOrder extends Command
|
||||||
if (! $userOrder->createShoppingUser()) {
|
if (! $userOrder->createShoppingUser()) {
|
||||||
\Log::channel('abo_order')->error('UserMakeAboOrder: Konnte Shopping-User nicht erstellen', ['abo_id' => $userAbo->id]);
|
\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");
|
$this->error("Konnte Shopping-User für Abo {$userAbo->id} nicht erstellen");
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -189,12 +195,13 @@ class UserMakeAboOrder extends Command
|
||||||
if (! $shoppingOrder) {
|
if (! $shoppingOrder) {
|
||||||
\Log::channel('abo_order')->error('UserMakeAboOrder: Konnte Bestellung nicht erstellen', ['abo_id' => $userAbo->id]);
|
\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");
|
$this->error("Konnte Bestellung für Abo {$userAbo->id} nicht erstellen");
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
\Log::channel('abo_order')->info('UserMakeAboOrder: Bestellung erstellt, starte Zahlungsvorgang', [
|
\Log::channel('abo_order')->info('UserMakeAboOrder: Bestellung erstellt, starte Zahlungsvorgang', [
|
||||||
'abo_id' => $userAbo->id,
|
'abo_id' => $userAbo->id,
|
||||||
'order_id' => $shoppingOrder->id
|
'order_id' => $shoppingOrder->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response = $userOrder->makePayment();
|
$response = $userOrder->makePayment();
|
||||||
|
|
@ -209,13 +216,14 @@ class UserMakeAboOrder extends Command
|
||||||
\Log::channel('abo_order')->error('UserMakeAboOrder: Ungültige Zahlungsantwort', [
|
\Log::channel('abo_order')->error('UserMakeAboOrder: Ungültige Zahlungsantwort', [
|
||||||
'abo_id' => $userAbo->id,
|
'abo_id' => $userAbo->id,
|
||||||
'order_id' => $shoppingOrder->id,
|
'order_id' => $shoppingOrder->id,
|
||||||
'response' => $response
|
'response' => $response,
|
||||||
]);
|
]);
|
||||||
$this->error("Ungültige Zahlungsantwort für Abo {$userAbo->id}");
|
$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
|
// Bei fehlender Status-Information: Abo nicht aktualisieren, damit es beim nächsten Lauf erneut versucht wird
|
||||||
// Aber Bestellung speichern für Nachverfolgung
|
// Aber Bestellung speichern für Nachverfolgung
|
||||||
$this->updateAboOnError($userAbo, $shoppingOrder, 'Ungültige Zahlungsantwort - kein Status');
|
$this->updateAboOnError($userAbo, $shoppingOrder, 'Ungültige Zahlungsantwort - kein Status');
|
||||||
|
|
||||||
return $shoppingOrder;
|
return $shoppingOrder;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -223,7 +231,7 @@ class UserMakeAboOrder extends Command
|
||||||
\Log::channel('abo_order')->info('UserMakeAboOrder: Zahlung erfolgreich', [
|
\Log::channel('abo_order')->info('UserMakeAboOrder: Zahlung erfolgreich', [
|
||||||
'abo_id' => $userAbo->id,
|
'abo_id' => $userAbo->id,
|
||||||
'order_id' => $shoppingOrder->id,
|
'order_id' => $shoppingOrder->id,
|
||||||
'response' => $response
|
'response' => $response,
|
||||||
]);
|
]);
|
||||||
$this->info("Zahlung erfolgreich für Abo {$userAbo->id}");
|
$this->info("Zahlung erfolgreich für Abo {$userAbo->id}");
|
||||||
// Nur bei erfolgreicher Zahlung: next_date aktualisieren
|
// Nur bei erfolgreicher Zahlung: next_date aktualisieren
|
||||||
|
|
@ -232,7 +240,7 @@ class UserMakeAboOrder extends Command
|
||||||
\Log::channel('abo_order')->error('UserMakeAboOrder: Zahlungsfehler', [
|
\Log::channel('abo_order')->error('UserMakeAboOrder: Zahlungsfehler', [
|
||||||
'abo_id' => $userAbo->id,
|
'abo_id' => $userAbo->id,
|
||||||
'order_id' => $shoppingOrder->id,
|
'order_id' => $shoppingOrder->id,
|
||||||
'error' => $response
|
'error' => $response,
|
||||||
]);
|
]);
|
||||||
$this->error("Zahlungsfehler für Abo {$userAbo->id}");
|
$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', [
|
\Log::channel('abo_order')->info('UserMakeAboOrder: Zahlung ausstehend/weiterleitung', [
|
||||||
'abo_id' => $userAbo->id,
|
'abo_id' => $userAbo->id,
|
||||||
'order_id' => $shoppingOrder->id,
|
'order_id' => $shoppingOrder->id,
|
||||||
'status' => $response['status']
|
'status' => $response['status'],
|
||||||
]);
|
]);
|
||||||
$this->info("Zahlung ausstehend für Abo {$userAbo->id}: {$response['status']}");
|
$this->info("Zahlung ausstehend für Abo {$userAbo->id}: {$response['status']}");
|
||||||
$this->updateAboOnError($userAbo, $shoppingOrder, 'Zahlung ausstehend: ' . $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', [
|
\Log::channel('abo_order')->warning('UserMakeAboOrder: Unbekannter Zahlungsstatus', [
|
||||||
'abo_id' => $userAbo->id,
|
'abo_id' => $userAbo->id,
|
||||||
'order_id' => $shoppingOrder->id,
|
'order_id' => $shoppingOrder->id,
|
||||||
'status' => $response['status']
|
'status' => $response['status'],
|
||||||
]);
|
]);
|
||||||
$this->warn("Unbekannter Zahlungsstatus für Abo {$userAbo->id}: {$response['status']}");
|
$this->warn("Unbekannter Zahlungsstatus für Abo {$userAbo->id}: {$response['status']}");
|
||||||
$this->updateAboOnError($userAbo, $shoppingOrder, 'Unbekannter Status: ' . $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', [
|
\Log::channel('abo_order')->error('UserMakeAboOrder: Ausnahme bei der Bestellungserstellung', [
|
||||||
'abo_id' => $userAbo->id,
|
'abo_id' => $userAbo->id,
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
'trace' => $e->getTraceAsString()
|
'trace' => $e->getTraceAsString(),
|
||||||
]);
|
]);
|
||||||
$this->error("Ausnahme bei Abo {$userAbo->id}: " . $e->getMessage());
|
$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) {
|
if ($shoppingOrder) {
|
||||||
$this->updateAboOnError($userAbo, $shoppingOrder, 'Exception: ' . $e->getMessage());
|
$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;
|
return $shoppingOrder;
|
||||||
|
|
@ -308,7 +322,7 @@ class UserMakeAboOrder extends Command
|
||||||
\Log::channel('abo_order')->info('UserMakeAboOrder: Aktualisiere Abo nach erfolgreicher Zahlung', [
|
\Log::channel('abo_order')->info('UserMakeAboOrder: Aktualisiere Abo nach erfolgreicher Zahlung', [
|
||||||
'abo_id' => $userAbo->id,
|
'abo_id' => $userAbo->id,
|
||||||
'order_id' => $shoppingOrder->id,
|
'order_id' => $shoppingOrder->id,
|
||||||
'status' => $status
|
'status' => $status,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->info("Aktualisiere Abo: {$userAbo->id} mit Status {$status}");
|
$this->info("Aktualisiere Abo: {$userAbo->id} mit Status {$status}");
|
||||||
|
|
@ -330,18 +344,22 @@ class UserMakeAboOrder extends Command
|
||||||
'user_abo_id' => $userAbo->id,
|
'user_abo_id' => $userAbo->id,
|
||||||
'shopping_order_id' => $shoppingOrder->id,
|
'shopping_order_id' => $shoppingOrder->id,
|
||||||
'status' => $status,
|
'status' => $status,
|
||||||
'paid' => false,
|
'paid' => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
\Log::channel('abo_order')->info('UserMakeAboOrder: Abo erfolgreich aktualisiert', [
|
\Log::channel('abo_order')->info('UserMakeAboOrder: Abo erfolgreich aktualisiert', [
|
||||||
'abo_id' => $userAbo->id,
|
'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) {
|
} catch (\Exception $e) {
|
||||||
\Log::channel('abo_order')->error('UserMakeAboOrder: Fehler beim Aktualisieren des Abos', [
|
\Log::channel('abo_order')->error('UserMakeAboOrder: Fehler beim Aktualisieren des Abos', [
|
||||||
'abo_id' => $userAbo->id,
|
'abo_id' => $userAbo->id,
|
||||||
'error' => $e->getMessage()
|
'error' => $e->getMessage(),
|
||||||
]);
|
]);
|
||||||
$this->error("Fehler beim Aktualisieren des Abos {$userAbo->id}: " . $e->getMessage());
|
$this->error("Fehler beim Aktualisieren des Abos {$userAbo->id}: " . $e->getMessage());
|
||||||
throw $e; // Re-throw für besseres Error-Handling
|
throw $e; // Re-throw für besseres Error-Handling
|
||||||
|
|
@ -363,7 +381,7 @@ class UserMakeAboOrder extends Command
|
||||||
\Log::channel('abo_order')->info('UserMakeAboOrder: Aktualisiere Abo bei Fehler (ohne next_date)', [
|
\Log::channel('abo_order')->info('UserMakeAboOrder: Aktualisiere Abo bei Fehler (ohne next_date)', [
|
||||||
'abo_id' => $userAbo->id,
|
'abo_id' => $userAbo->id,
|
||||||
'order_id' => $shoppingOrder->id,
|
'order_id' => $shoppingOrder->id,
|
||||||
'status' => $status
|
'status' => $status,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->info("Aktualisiere Abo bei Fehler: {$userAbo->id} (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)', [
|
\Log::channel('abo_order')->info('UserMakeAboOrder: Abo bei Fehler aktualisiert (next_date unverändert)', [
|
||||||
'abo_id' => $userAbo->id,
|
'abo_id' => $userAbo->id,
|
||||||
'next_date' => $userAbo->next_date,
|
'next_date' => $userAbo->next_date,
|
||||||
'status' => $status
|
'status' => $status,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
\Log::channel('abo_order')->error('UserMakeAboOrder: Fehler beim Aktualisieren des Abos bei Fehler', [
|
\Log::channel('abo_order')->error('UserMakeAboOrder: Fehler beim Aktualisieren des Abos bei Fehler', [
|
||||||
'abo_id' => $userAbo->id,
|
'abo_id' => $userAbo->id,
|
||||||
'error' => $e->getMessage()
|
'error' => $e->getMessage(),
|
||||||
]);
|
]);
|
||||||
$this->error("Fehler beim Aktualisieren des Abos {$userAbo->id}: " . $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
|
// Bei Fehler hier nicht re-throw, damit der Hauptprozess fortgesetzt werden kann
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,12 @@ class Kernel extends ConsoleKernel
|
||||||
$schedule->command('user:cleanup')->dailyAt('03:30');
|
$schedule->command('user:cleanup')->dailyAt('03:30');
|
||||||
$schedule->command('user:make_abo_order')->dailyAt('04:00');
|
$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)
|
// Cleanup old log files weekly (keeps logs for 30 days)
|
||||||
$schedule->command('logs:cleanup --days=30')->weekly()->sundays()->at('05:00');
|
$schedule->command('logs:cleanup --days=30')->weekly()->sundays()->at('05:00');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,23 +2,27 @@
|
||||||
|
|
||||||
namespace App\Cron;
|
namespace App\Cron;
|
||||||
|
|
||||||
use Yard;
|
use App\Http\Controllers\Pay\PayoneController;
|
||||||
use App\Models\UserAbo;
|
|
||||||
use App\Models\ShoppingOrder;
|
use App\Models\ShoppingOrder;
|
||||||
use App\Models\ShoppingOrderItem;
|
use App\Models\ShoppingOrderItem;
|
||||||
use App\Http\Controllers\Pay\PayoneController;
|
use App\Models\UserAbo;
|
||||||
use App\Services\AboOrderCart;
|
use App\Services\AboOrderCart;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Yard;
|
||||||
|
|
||||||
class UserMakeOrder
|
class UserMakeOrder
|
||||||
{
|
{
|
||||||
private $userAbo;
|
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)
|
public function __construct(UserAbo $userAbo)
|
||||||
{
|
{
|
||||||
|
|
@ -26,7 +30,6 @@ class UserMakeOrder
|
||||||
Log::info('UserMakeOrder initialisiert für UserAbo ID: '.$userAbo->id);
|
Log::info('UserMakeOrder initialisiert für UserAbo ID: '.$userAbo->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public function checkProducts()
|
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);
|
||||||
|
|
@ -34,6 +37,7 @@ class UserMakeOrder
|
||||||
|
|
||||||
if (! $this->userAbo->items || $this->userAbo->items->isEmpty()) {
|
if (! $this->userAbo->items || $this->userAbo->items->isEmpty()) {
|
||||||
Log::warning('Keine Artikel für UserAbo ID: '.$this->userAbo->id.' gefunden');
|
Log::warning('Keine Artikel für UserAbo ID: '.$this->userAbo->id.' gefunden');
|
||||||
|
|
||||||
return $ret;
|
return $ret;
|
||||||
}
|
}
|
||||||
// preise prüfen, ob sie sich geändert haben?
|
// preise prüfen, ob sie sich geändert haben?
|
||||||
|
|
@ -53,6 +57,7 @@ class UserMakeOrder
|
||||||
}
|
}
|
||||||
|
|
||||||
Log::info('Produkte überprüft: '.count($ret).' Artikel gefunden');
|
Log::info('Produkte überprüft: '.count($ret).' Artikel gefunden');
|
||||||
|
|
||||||
return $ret;
|
return $ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -61,9 +66,11 @@ class UserMakeOrder
|
||||||
Log::info('Starte Zahlungsvorgang für UserAbo ID: '.$this->userAbo->id);
|
Log::info('Starte Zahlungsvorgang für UserAbo ID: '.$this->userAbo->id);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->pay = new PayoneController();
|
$this->pay = new PayoneController;
|
||||||
$this->pay->init($this->shopping_user, $this->shopping_order);
|
$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->setAboPayment($this->userAbo, $amount, 'EUR');
|
||||||
$this->pay->setPersonalData();
|
$this->pay->setPersonalData();
|
||||||
$response = $this->pay->onlyPaymentResponse();
|
$response = $this->pay->onlyPaymentResponse();
|
||||||
|
|
@ -71,6 +78,7 @@ class UserMakeOrder
|
||||||
// $response = $this->pay->ResponseData(true);
|
// $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;
|
return $response;
|
||||||
} catch (\Exception $e) {
|
} 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());
|
||||||
|
|
@ -85,10 +93,12 @@ class UserMakeOrder
|
||||||
if ($this->pay) {
|
if ($this->pay) {
|
||||||
$payment = $this->pay->getShoppingPayment();
|
$payment = $this->pay->getShoppingPayment();
|
||||||
Log::info('Zahlungsinformationen abgerufen: '.($payment ? 'erfolgreich' : 'nicht verfügbar'));
|
Log::info('Zahlungsinformationen abgerufen: '.($payment ? 'erfolgreich' : 'nicht verfügbar'));
|
||||||
|
|
||||||
return $payment;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -103,15 +113,12 @@ class UserMakeOrder
|
||||||
$this->shopping_user->save();
|
$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;
|
return $this->shopping_user;
|
||||||
} catch (\Exception $e) {
|
} catch (\Throwable $e) {
|
||||||
Log::error('Fehler beim Erstellen des Shopping-Users für UserAbo ID: '.$this->userAbo->id.': '.$e->getMessage());
|
Log::error('Fehler beim Erstellen des Shopping-Users für UserAbo ID: '.$this->userAbo->id.': '.$e->getMessage());
|
||||||
throw $e;
|
throw $e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Log::warning('Kein Shopping-User verfügbar für UserAbo ID: ' . $this->userAbo->id);
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function makeShoppingOrder()
|
public function makeShoppingOrder()
|
||||||
|
|
@ -121,6 +128,7 @@ class UserMakeOrder
|
||||||
try {
|
try {
|
||||||
if (! $this->shopping_user) {
|
if (! $this->shopping_user) {
|
||||||
Log::error('Kein Shopping-User verfügbar für Bestellerstellung, UserAbo ID: '.$this->userAbo->id);
|
Log::error('Kein Shopping-User verfügbar für Bestellerstellung, UserAbo ID: '.$this->userAbo->id);
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -154,26 +162,57 @@ class UserMakeOrder
|
||||||
'product_id' => $item->id,
|
'product_id' => $item->id,
|
||||||
'name' => $item->name,
|
'name' => $item->name,
|
||||||
'qty' => $item->qty,
|
'qty' => $item->qty,
|
||||||
'rowId' => $item->rowId
|
'rowId' => $item->rowId,
|
||||||
];
|
];
|
||||||
})->toArray()
|
})->toArray(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$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,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
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);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->shopping_order = ShoppingOrder::create([
|
$this->shopping_order = ShoppingOrder::create([
|
||||||
'shopping_user_id' => $this->shopping_user->id,
|
'shopping_user_id' => $this->shopping_user->id,
|
||||||
'auth_user_id' => $this->shopping_user->auth_user_id,
|
'member_id' => $this->userAbo->member_id ?? $referenceOrder->member_id,
|
||||||
'country_id' => $yard->getShippingCountryId(),
|
'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(),
|
'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(),
|
'payment_for' => $this->shopping_user->getOrderPaymentFor(),
|
||||||
'total' => $yard->total(2, '.', ''),
|
'total' => $yard->total(2, '.', ''),
|
||||||
'subtotal' => $yard->subtotal(2, '.', ''),
|
'subtotal' => $yard->subtotal(2, '.', ''),
|
||||||
'shipping' => $yard->shipping(2, '.', ','),
|
'shipping' => $yard->shipping(2, '.', ''),
|
||||||
'shipping_net' => $yard->shippingNet(2, '.', ''),
|
'shipping_net' => $yard->shippingNet(2, '.', ''),
|
||||||
'subtotal_ws' => $yard->subtotalWithShipping(2, '.', ''),
|
'subtotal_ws' => $yard->subtotalWithShipping(2, '.', ''),
|
||||||
'tax' => $yard->taxWithShipping(2, '.', ''),
|
'tax' => $yard->taxWithShipping(2, '.', ''),
|
||||||
|
|
@ -183,7 +222,7 @@ class UserMakeOrder
|
||||||
'is_abo' => 1,
|
'is_abo' => 1,
|
||||||
'abo_interval' => $this->userAbo->abo_interval ?? 0,
|
'abo_interval' => $this->userAbo->abo_interval ?? 0,
|
||||||
'txaction' => 'prev',
|
'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);
|
||||||
|
|
@ -208,7 +247,7 @@ class UserMakeOrder
|
||||||
'price_vk_net' => $this->shopping_order->getPriceVkNetBy($item->id),
|
'price_vk_net' => $this->shopping_order->getPriceVkNetBy($item->id),
|
||||||
'discount' => $item->options->no_commission ? 0 : $this->shopping_order->getUserDiscount(),
|
'discount' => $item->options->no_commission ? 0 : $this->shopping_order->getUserDiscount(),
|
||||||
'points' => $item->options->points,
|
'points' => $item->options->points,
|
||||||
'slug' => $item->options->slug
|
'slug' => $item->options->slug,
|
||||||
];
|
];
|
||||||
ShoppingOrderItem::create($data);
|
ShoppingOrderItem::create($data);
|
||||||
$itemCount++;
|
$itemCount++;
|
||||||
|
|
@ -221,7 +260,7 @@ class UserMakeOrder
|
||||||
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;
|
return $this->shopping_order;
|
||||||
} catch (\Exception $e) {
|
} catch (\Throwable $e) {
|
||||||
Log::error('Fehler bei Bestellerstellung für UserAbo ID: '.$this->userAbo->id.': '.$e->getMessage());
|
Log::error('Fehler bei Bestellerstellung für UserAbo ID: '.$this->userAbo->id.': '.$e->getMessage());
|
||||||
throw $e;
|
throw $e;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
411
app/Http/Controllers/Admin/IncentiveController.php
Normal file
411
app/Http/Controllers/Admin/IncentiveController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -105,29 +105,39 @@ class PayoneController extends Controller
|
||||||
echo 'TSOK';
|
echo 'TSOK';
|
||||||
exit;
|
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 ($shopping_payment->txaction == $data['txaction']) {
|
||||||
|
|
||||||
if ($data['txaction'] === 'paid' && $shopping_order->txaction === 'paid') {
|
if ($data['txaction'] === 'paid' && $shopping_order->txaction === 'paid') {
|
||||||
MyLog::writeLog(
|
MyLog::writeLog(
|
||||||
'payone',
|
'payone',
|
||||||
'error',
|
'notice',
|
||||||
'Error:2007 App\Http\Controllers\Api\PayoneController::paymentStatus same txaction - was already paid',
|
'App\Http\Controllers\Api\PayoneController::paymentStatus duplicate callback ignored (already paid)',
|
||||||
$data,
|
$data,
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
// was already paid
|
|
||||||
echo 'TSOK';
|
echo 'TSOK';
|
||||||
exit;
|
exit;
|
||||||
} else {
|
}
|
||||||
|
|
||||||
|
if (in_array($data['txaction'], ['appointed', 'failed', 'pending'], true)) {
|
||||||
MyLog::writeLog(
|
MyLog::writeLog(
|
||||||
'payone',
|
'payone',
|
||||||
'error',
|
'info',
|
||||||
'Error:2007 App\Http\Controllers\Api\PayoneController::paymentStatus same txaction - show',
|
'App\Http\Controllers\Api\PayoneController::paymentStatus duplicate callback ignored (same txaction)',
|
||||||
$data,
|
[
|
||||||
|
'reference' => $data['reference'] ?? null,
|
||||||
|
'param' => $data['param'] ?? null,
|
||||||
|
'txaction' => $data['txaction'],
|
||||||
|
'txid' => $data['txid'] ?? null,
|
||||||
|
],
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
|
echo 'TSOK';
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -191,7 +201,6 @@ class PayoneController extends Controller
|
||||||
$locked_order = ShoppingOrder::where('id', $shopping_order->id)
|
$locked_order = ShoppingOrder::where('id', $shopping_order->id)
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
// Double-check if payment was already processed
|
// Double-check if payment was already processed
|
||||||
if (! $locked_order->paid) {
|
if (! $locked_order->paid) {
|
||||||
$send_link = Payment::paymentStatusPaidAction($locked_order, true, $shopping_payment);
|
$send_link = Payment::paymentStatusPaidAction($locked_order, true, $shopping_payment);
|
||||||
|
|
@ -211,6 +220,7 @@ class PayoneController extends Controller
|
||||||
throw $e;
|
throw $e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$data['send_link'] = $send_link;
|
$data['send_link'] = $send_link;
|
||||||
if ($send_mail) {
|
if ($send_mail) {
|
||||||
Payment::paymentStatusSendMail($shopping_order, $shopping_payment, $data);
|
Payment::paymentStatusSendMail($shopping_order, $shopping_payment, $data);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\DashboardNews;
|
||||||
|
use App\Models\Incentive;
|
||||||
|
use App\Models\IncentiveParticipant;
|
||||||
use App\Models\ShoppingPayment;
|
use App\Models\ShoppingPayment;
|
||||||
use App\User;
|
use App\User;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
|
|
@ -20,6 +23,7 @@ class HomeController extends Controller
|
||||||
|
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
|
|
||||||
if (! Auth::check()) {
|
if (! Auth::check()) {
|
||||||
return redirect('login');
|
return redirect('login');
|
||||||
}
|
}
|
||||||
|
|
@ -43,10 +47,26 @@ class HomeController extends Controller
|
||||||
return redirect('login');
|
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 = [
|
$data = [
|
||||||
'user' => Auth::user(),
|
'user' => $user,
|
||||||
'now' => Carbon::now(),
|
'now' => Carbon::now(),
|
||||||
'dashboardNews' => \App\Models\DashboardNews::getActiveNews(),
|
'dashboardNews' => DashboardNews::getActiveNews(),
|
||||||
|
'activeIncentive' => $activeIncentive,
|
||||||
|
'incentiveParticipant' => $incentiveParticipant,
|
||||||
];
|
];
|
||||||
|
|
||||||
return view('home', $data);
|
return view('home', $data);
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ use App\Services\Payment;
|
||||||
use App\Services\Util;
|
use App\Services\Util;
|
||||||
use App\User;
|
use App\User;
|
||||||
use Carbon;
|
use Carbon;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
use Request;
|
use Request;
|
||||||
|
|
||||||
class PaymentCreditController extends Controller
|
class PaymentCreditController extends Controller
|
||||||
|
|
@ -183,6 +184,36 @@ class PaymentCreditController extends Controller
|
||||||
return $query;
|
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()
|
public function datatable()
|
||||||
{
|
{
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,15 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use Carbon;
|
|
||||||
use Request;
|
|
||||||
use App\Services\Payment;
|
|
||||||
use App\Models\UserInvoice;
|
use App\Models\UserInvoice;
|
||||||
use App\Services\HTMLHelper;
|
use App\Services\HTMLHelper;
|
||||||
|
use App\Services\Payment;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Request;
|
||||||
|
|
||||||
class PaymentInvoiceController extends Controller
|
class PaymentInvoiceController extends Controller
|
||||||
{
|
{
|
||||||
|
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->middleware('admin');
|
$this->middleware('admin');
|
||||||
|
|
@ -26,6 +23,7 @@ class PaymentInvoiceController extends Controller
|
||||||
'filter_months' => HTMLHelper::getTransMonths(),
|
'filter_months' => HTMLHelper::getTransMonths(),
|
||||||
'filter_years' => HTMLHelper::getYearRange(),
|
'filter_years' => HTMLHelper::getYearRange(),
|
||||||
];
|
];
|
||||||
|
|
||||||
return view('admin.payment.invoice', $data);
|
return view('admin.payment.invoice', $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -64,9 +62,41 @@ class PaymentInvoiceController extends Controller
|
||||||
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();
|
})->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
return $query;
|
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()
|
public function datatable()
|
||||||
{
|
{
|
||||||
|
|
||||||
|
|
@ -77,19 +107,21 @@ class PaymentInvoiceController extends Controller
|
||||||
if ($UserInvoice->shopping_order->auth_user_id) {
|
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) {
|
->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) {
|
->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) {
|
->addColumn('txaction', function (UserInvoice $UserInvoice) {
|
||||||
if ($UserInvoice->shopping_order) {
|
if ($UserInvoice->shopping_order) {
|
||||||
return Payment::getShoppingOrderBadge($UserInvoice->shopping_order);
|
return Payment::getShoppingOrderBadge($UserInvoice->shopping_order);
|
||||||
}
|
}
|
||||||
return "-";
|
|
||||||
|
return '-';
|
||||||
})
|
})
|
||||||
->addColumn('status', function (UserInvoice $UserInvoice) {
|
->addColumn('status', function (UserInvoice $UserInvoice) {
|
||||||
return '<a href="#" data-toggle="modal" data-target="#modals-load-content" data-modal="modal-lg"
|
return '<a href="#" data-toggle="modal" data-target="#modals-load-content" data-modal="modal-lg"
|
||||||
|
|
@ -98,9 +130,10 @@ class PaymentInvoiceController extends Controller
|
||||||
</a>';
|
</a>';
|
||||||
})
|
})
|
||||||
->addColumn('invoice', function (UserInvoice $UserInvoice) {
|
->addColumn('invoice', function (UserInvoice $UserInvoice) {
|
||||||
$ret = "";
|
$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', '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 .= '<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;
|
return $ret;
|
||||||
})
|
})
|
||||||
->orderColumn('id', 'id $1')
|
->orderColumn('id', 'id $1')
|
||||||
|
|
|
||||||
|
|
@ -403,16 +403,20 @@ class AboController extends Controller
|
||||||
$data['step'] = 4;
|
$data['step'] = 4;
|
||||||
break;
|
break;
|
||||||
case 5:
|
case 5:
|
||||||
// chekout verarbeiten
|
|
||||||
UserService::setInstance($this->instance);
|
UserService::setInstance($this->instance);
|
||||||
UserService::initCustomerYard($shopping_user, 'abo-ot-customer');
|
UserService::initCustomerYard($shopping_user, 'abo-ot-customer');
|
||||||
if (Request::get('action') == 'checkout') {
|
if (Request::get('action') == 'checkout') {
|
||||||
// checkout verarbeiten
|
if (! Request::boolean('abo_order_info_checkbox')) {
|
||||||
if (! $this->preCheckCheckout()) {
|
$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['error'] = __('abo.abo_error_basis_product');
|
||||||
$data['step'] = 4;
|
$data['step'] = 4;
|
||||||
} else {
|
} else {
|
||||||
$data['checkout_url'] = $this->processCheckout();
|
$data['checkout_url'] = $this->processCheckout($shopping_user);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$data['step'] = 4;
|
$data['step'] = 4;
|
||||||
|
|
@ -439,18 +443,9 @@ class AboController extends Controller
|
||||||
Shop::initUserShopLang($delivery_country, $this->instance);
|
Shop::initUserShopLang($delivery_country, $this->instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function preCheckCheckout()
|
private function preCheckCheckout(): bool
|
||||||
{
|
{
|
||||||
$result = false;
|
return AboHelper::aboHasBaseProduct($this->yard->getContentByOrder());
|
||||||
// alle inhlate des warenkorb
|
|
||||||
$cartItems = $this->yard->content();
|
|
||||||
foreach ($cartItems as $item) {
|
|
||||||
if (in_array(12, $item->options->show_on)) {
|
|
||||||
$result = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function checkBasisProduct()
|
private function checkBasisProduct()
|
||||||
|
|
@ -550,7 +545,7 @@ class AboController extends Controller
|
||||||
$this->yard->reCalculateShippingPrice();
|
$this->yard->reCalculateShippingPrice();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function processCheckout()
|
private function processCheckout(ShoppingUser $shoppingUser): string
|
||||||
{
|
{
|
||||||
$user_shop = Util::getUserShop();
|
$user_shop = Util::getUserShop();
|
||||||
if (! $user_shop) {
|
if (! $user_shop) {
|
||||||
|
|
@ -560,24 +555,38 @@ class AboController extends Controller
|
||||||
$identifier = Util::getToken();
|
$identifier = Util::getToken();
|
||||||
} while (ShoppingInstance::where('identifier', $identifier)->count());
|
} while (ShoppingInstance::where('identifier', $identifier)->count());
|
||||||
|
|
||||||
$data = [];
|
$aboInterval = (int) Request::input('abo_interval', 0);
|
||||||
$data['is_from'] = 'shopping';
|
|
||||||
$data['user_price_infos'] = $this->yard->getUserPriceInfos();
|
$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([
|
ShoppingInstance::create([
|
||||||
'identifier' => $identifier,
|
'identifier' => $identifier,
|
||||||
'user_shop_id' => $user_shop->id,
|
'user_shop_id' => $user_shop->id,
|
||||||
'payment' => 1, // Customer Shop Payment
|
'payment' => 1,
|
||||||
'subdomain' => url('/'),
|
'subdomain' => url('/'),
|
||||||
'country_id' => $this->yard->getShippingCountryId(),
|
'country_id' => $this->yard->getShippingCountryId(),
|
||||||
'language' => \App::getLocale(),
|
'language' => $shoppingUser->getLocale(),
|
||||||
'shopping_data' => $data,
|
'amount' => (float) $this->yard->totalWithShipping(2, '.', ''),
|
||||||
|
'shopping_user_id' => $shoppingUser->id,
|
||||||
|
'shopping_data' => $shoppingData,
|
||||||
'back' => url()->previous(),
|
'back' => url()->previous(),
|
||||||
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->yard->store($identifier);
|
$this->yard->store($identifier);
|
||||||
// add to DB
|
|
||||||
$path = route('checkout.checkout_card', ['identifier' => $identifier]);
|
$path = route('checkout.checkout_card', ['identifier' => $identifier]);
|
||||||
if (strpos($path, 'https') === false) {
|
if (strpos($path, 'https') === false) {
|
||||||
$path = str_replace('http', 'https', $path);
|
$path = str_replace('http', 'https', $path);
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,11 @@ use App\Models\ShoppingOrder;
|
||||||
use App\Models\ShoppingPayment;
|
use App\Models\ShoppingPayment;
|
||||||
use App\Models\ShoppingUser;
|
use App\Models\ShoppingUser;
|
||||||
use App\Services\Payment;
|
use App\Services\Payment;
|
||||||
|
use App\Services\ProductOrderContext;
|
||||||
use App\Services\Shop;
|
use App\Services\Shop;
|
||||||
use App\Services\Util;
|
use App\Services\Util;
|
||||||
use Auth;
|
use Auth;
|
||||||
|
use Illuminate\Support\Facades\Session;
|
||||||
use Yard;
|
use Yard;
|
||||||
|
|
||||||
class OrderController extends Controller
|
class OrderController extends Controller
|
||||||
|
|
@ -192,7 +194,7 @@ class OrderController extends Controller
|
||||||
public function myOrderCreate(int $id)
|
public function myOrderCreate(int $id)
|
||||||
{
|
{
|
||||||
$user = Auth::guard('customers')->user();
|
$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) {
|
if ($shopping_order->shopping_user_id != $user->shopping_user_id) {
|
||||||
$shopping_user = ShoppingUser::findOrFail($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);
|
$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);
|
$delivery_country = $shopping_user->getDeliveryCountry(true);
|
||||||
|
|
||||||
\Session::put('user_init_country', strtolower($delivery_country->code));
|
\Session::put('user_init_country', strtolower($delivery_country->code));
|
||||||
|
|
@ -211,18 +220,18 @@ class OrderController extends Controller
|
||||||
Shop::initUserShopLang($delivery_country, $this->instance);
|
Shop::initUserShopLang($delivery_country, $this->instance);
|
||||||
|
|
||||||
foreach ($shopping_order->shopping_order_items as $item) {
|
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);
|
$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
|
private function addToCart(int $productId, int $quantity = 1): void
|
||||||
{
|
{
|
||||||
$product = Product::find($productId);
|
$product = Product::find($productId);
|
||||||
if (! $product) {
|
if (! $product || ! ProductOrderContext::isProductAllowedInCustomerWebshop($product)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
42
app/Http/Controllers/SAdmin/SAdminController.php
Normal file
42
app/Http/Controllers/SAdmin/SAdminController.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,23 +2,23 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers\SyS;
|
namespace App\Http\Controllers\SyS;
|
||||||
|
|
||||||
use Carbon;
|
use App\Http\Controllers\Controller;
|
||||||
use Request;
|
use App\Services\SyS\AboOrdersOverview;
|
||||||
use App\Services\SyS\Sales;
|
use App\Services\SyS\BusinessStructur;
|
||||||
use App\Services\SyS\Import;
|
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\Cronjobs;
|
||||||
use App\Services\SyS\Customers;
|
use App\Services\SyS\Customers;
|
||||||
use App\Services\SyS\DomainSSL;
|
use App\Services\SyS\DomainSSL;
|
||||||
use App\Services\SyS\Correction;
|
use App\Services\SyS\Import;
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use App\Services\SyS\ShoppingOrders;
|
|
||||||
use App\Services\SyS\BuyingsProducts;
|
|
||||||
use App\Services\SyS\BusinessStructur;
|
|
||||||
use App\Services\SyS\ImportDbipCountry;
|
use App\Services\SyS\ImportDbipCountry;
|
||||||
use App\Services\SyS\ChangeUserBusinesses;
|
use App\Services\SyS\PayoneCallbackTestbench;
|
||||||
use App\Services\SyS\UserCreditItemsAddFrom;
|
|
||||||
use App\Services\SyS\RepairSalesVolumeInvoice;
|
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;
|
use App\Services\SyS\UserCreditItemsChangeMessage;
|
||||||
|
|
||||||
class SysController extends Controller
|
class SysController extends Controller
|
||||||
|
|
@ -28,7 +28,6 @@ class SysController extends Controller
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->middleware('sysadmin');
|
$this->middleware('sysadmin');
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function index()
|
public function index()
|
||||||
|
|
@ -85,11 +84,12 @@ class SysController extends Controller
|
||||||
case 'import_dbip_country_lite':
|
case 'import_dbip_country_lite':
|
||||||
return ImportDbipCountry::show();
|
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');
|
||||||
}
|
}
|
||||||
|
|
@ -142,6 +142,9 @@ class SysController extends Controller
|
||||||
case 'import_dbip_country_lite':
|
case 'import_dbip_country_lite':
|
||||||
return ImportDbipCountry::store();
|
return ImportDbipCountry::store();
|
||||||
break;
|
break;
|
||||||
|
case 'payone_callback_testbench':
|
||||||
|
return PayoneCallbackTestbench::store();
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
abort(403, 'not found tool');
|
abort(403, 'not found tool');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,9 +45,13 @@ class AboController extends Controller
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($view === 'ot') {
|
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('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')
|
->orderBy('id', 'desc')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
|
|
@ -55,6 +59,10 @@ class AboController extends Controller
|
||||||
'user_abos' => $user_abos,
|
'user_abos' => $user_abos,
|
||||||
'view' => 'ot',
|
'view' => 'ot',
|
||||||
'isAdmin' => false,
|
'isAdmin' => false,
|
||||||
|
'chartData' => AboHelper::getMonthlyAboCounts($baseQuery, $selectedYear, 'ot', \Auth::user()->id),
|
||||||
|
'chartYear' => $selectedYear,
|
||||||
|
'chartYears' => \App\Services\HTMLHelper::getYearRange(2026),
|
||||||
|
'chartMonths' => \App\Services\HTMLHelper::getTransMonths(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
200
app/Http/Controllers/User/IncentiveController.php
Normal file
200
app/Http/Controllers/User/IncentiveController.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -63,7 +63,6 @@ class MembershipController extends Controller
|
||||||
if ($user->isActiveAccount() && ! $user->isActiveShop()) {
|
if ($user->isActiveAccount() && ! $user->isActiveShop()) {
|
||||||
$payment_greaterThan = Carbon::parse($user->payment_account)->modify('-'.(config('mivita.renewal_days') + 1).' days');
|
$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();
|
$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();
|
$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);
|
return view('user.membership.index', $data);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function checkShoppingCountry($user)
|
private function checkShoppingCountry($user)
|
||||||
|
|
@ -158,8 +156,11 @@ class MembershipController extends Controller
|
||||||
if ($product->images->count()) {
|
if ($product->images->count()) {
|
||||||
$image = $product->images->first()->slug;
|
$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]);
|
$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()) {
|
if (\App\Services\UserService::getTaxFree()) {
|
||||||
Yard::setTax($cartItem->rowId, 0);
|
Yard::setTax($cartItem->rowId, 0);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -214,7 +215,6 @@ class MembershipController extends Controller
|
||||||
\Session()->flash('alert-success', __('msg.booked_package_has_been_changed'));
|
\Session()->flash('alert-success', __('msg.booked_package_has_been_changed'));
|
||||||
|
|
||||||
return back();
|
return back();
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -236,11 +236,9 @@ class MembershipController extends Controller
|
||||||
\Session()->flash('alert-error', __('msg.error_checkbox_not_confirm'));
|
\Session()->flash('alert-error', __('msg.error_checkbox_not_confirm'));
|
||||||
|
|
||||||
return back();
|
return back();
|
||||||
|
|
||||||
}
|
}
|
||||||
\Session()->flash('alert-error', __('msg.error_checkbox_not_confirm'));
|
\Session()->flash('alert-error', __('msg.error_checkbox_not_confirm'));
|
||||||
|
|
||||||
return back();
|
return back();
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ use App\Services\AboHelper;
|
||||||
use App\Services\MyLog;
|
use App\Services\MyLog;
|
||||||
use App\Services\OrderPaymentService;
|
use App\Services\OrderPaymentService;
|
||||||
use App\Services\Payment;
|
use App\Services\Payment;
|
||||||
|
use App\Services\ProductOrderContext;
|
||||||
use App\Services\Shop;
|
use App\Services\Shop;
|
||||||
use App\Services\UserService;
|
use App\Services\UserService;
|
||||||
use App\Services\Util;
|
use App\Services\Util;
|
||||||
|
|
@ -182,6 +183,16 @@ class OrderController extends Controller
|
||||||
$delivery_id = $shopping_user->id;
|
$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') {
|
if ($for === 'ot-customer' || $for === 'abo-ot-customer') {
|
||||||
UserService::initCustomerYard($shopping_user, $for);
|
UserService::initCustomerYard($shopping_user, $for);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -262,7 +273,7 @@ class OrderController extends Controller
|
||||||
// Prepare common data
|
// Prepare common data
|
||||||
$data['is_from'] = 'user_order';
|
$data['is_from'] = 'user_order';
|
||||||
$data['is_for'] = $for;
|
$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['abo_interval'] = $data['abo_interval'] ?? 0;
|
||||||
$data['shopping_user_id'] = $id;
|
$data['shopping_user_id'] = $id;
|
||||||
$data['user_price_infos'] = Yard::instance('shopping')->getUserPriceInfos();
|
$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'));
|
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 ($data['shipping_is_for'] !== 'ot-customer') {
|
||||||
if (Yard::instance('shopping')->shipping_free) {
|
if (Yard::instance('shopping')->shipping_free) {
|
||||||
$identifier = 'error-'.time().mt_rand(1000000, 9999999);
|
$identifier = 'error-'.time().mt_rand(1000000, 9999999);
|
||||||
|
|
@ -748,6 +770,15 @@ class OrderController extends Controller
|
||||||
return response()->json(['response' => false, 'message' => 'Product not found']);
|
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 = '';
|
$image = '';
|
||||||
if ($product->images->count()) {
|
if ($product->images->count()) {
|
||||||
$image = $product->images->first()->slug;
|
$image = $product->images->first()->slug;
|
||||||
|
|
|
||||||
|
|
@ -713,9 +713,13 @@ class TeamController extends Controller
|
||||||
// Hole Team-Mitglieder-IDs effizient via Sponsor-Hierarchie
|
// Hole Team-Mitglieder-IDs effizient via Sponsor-Hierarchie
|
||||||
$teamUserIds = AboHelper::getTeamUserIds($user->id);
|
$teamUserIds = AboHelper::getTeamUserIds($user->id);
|
||||||
|
|
||||||
// Hole Abos der Team-Mitglieder
|
$selectedYear = (int) Request::get('year', now()->year);
|
||||||
$abos = \App\Models\UserAbo::whereIn('user_id', $teamUserIds)
|
$baseQuery = \App\Models\UserAbo::whereIn('user_id', $teamUserIds)
|
||||||
->where('is_for', 'me')
|
->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'])
|
->with(['user', 'user.account', 'user_abo_items', 'user_abo_items.product'])
|
||||||
->orderBy('next_date', 'asc')
|
->orderBy('next_date', 'asc')
|
||||||
->get();
|
->get();
|
||||||
|
|
@ -724,11 +728,45 @@ class TeamController extends Controller
|
||||||
'filter_months' => HTMLHelper::getTransMonths(),
|
'filter_months' => HTMLHelper::getTransMonths(),
|
||||||
'filter_years' => HTMLHelper::getYearRange(2022),
|
'filter_years' => HTMLHelper::getYearRange(2022),
|
||||||
'abos' => $abos,
|
'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);
|
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
|
* Zeigt die Detail-Ansicht eines Team-Abos an
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -2,43 +2,46 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers\Web;
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
use Yard;
|
use App\Models\Product;
|
||||||
use Request;
|
use App\Models\ShoppingInstance;
|
||||||
|
use App\Models\ShoppingUser;
|
||||||
|
use App\Services\ProductOrderContext;
|
||||||
use App\Services\Shop;
|
use App\Services\Shop;
|
||||||
use App\Services\Util;
|
use App\Services\Util;
|
||||||
use App\Models\Product;
|
use Request;
|
||||||
use App\Models\ShoppingUser;
|
use Yard;
|
||||||
use App\Models\ShoppingInstance;
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
|
|
||||||
class CardController extends Controller
|
class CardController extends Controller
|
||||||
{
|
{
|
||||||
private $instance = 'webshop';
|
private $instance = 'webshop';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new controller instance.
|
* Create a new controller instance.
|
||||||
*
|
*
|
||||||
* @return void
|
* @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)
|
public function addToCardGet($id, $quantity = 1, $product_slug = false)
|
||||||
{
|
{
|
||||||
$product = Product::find($id);
|
$product = Product::find($id);
|
||||||
if($product){
|
if ($product && ProductOrderContext::isProductAllowedInCustomerWebshop($product)) {
|
||||||
$image = "";
|
$image = '';
|
||||||
if ($product->images->count()) {
|
if ($product->images->count()) {
|
||||||
$image = $product->images->first()->slug;
|
$image = $product->images->first()->slug;
|
||||||
}
|
}
|
||||||
$cartItem = Yard::instance($this->instance)
|
$cartItem = Yard::instance($this->instance)
|
||||||
->add($product->id, $product->getLang('name'), $quantity,
|
->add(
|
||||||
$product->getPriceWith(Yard::instance($this->instance)->getUserTaxFree(), false, Yard::instance($this->instance)->getUserCountry()), false, false,
|
$product->id,
|
||||||
['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]);
|
$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()) {
|
if (Yard::instance($this->instance)->getUserTaxFree()) {
|
||||||
Yard::setTax($cartItem->rowId, 0);
|
Yard::setTax($cartItem->rowId, 0);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -50,7 +53,6 @@ class CardController extends Controller
|
||||||
}
|
}
|
||||||
|
|
||||||
return back();
|
return back();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function addToCardPost($id)
|
public function addToCardPost($id)
|
||||||
|
|
@ -58,16 +60,22 @@ class CardController extends Controller
|
||||||
|
|
||||||
$product = Product::find($id);
|
$product = Product::find($id);
|
||||||
|
|
||||||
if($product){
|
if ($product && ProductOrderContext::isProductAllowedInCustomerWebshop($product)) {
|
||||||
$image = "";
|
$image = '';
|
||||||
if ($product->images->count()) {
|
if ($product->images->count()) {
|
||||||
$image = $product->images->first()->slug;
|
$image = $product->images->first()->slug;
|
||||||
}
|
}
|
||||||
$quantity = Request::get('quantity') ? Request::get('quantity') : 1;
|
$quantity = Request::get('quantity') ? Request::get('quantity') : 1;
|
||||||
$cartItem = Yard::instance($this->instance)
|
$cartItem = Yard::instance($this->instance)
|
||||||
->add($product->id, $product->getLang('name'), $quantity,
|
->add(
|
||||||
$product->getPriceWith(Yard::instance($this->instance)->getUserTaxFree(), false, Yard::instance($this->instance)->getUserCountry()), false, false,
|
$product->id,
|
||||||
['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]);
|
$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()) {
|
if (Yard::instance($this->instance)->getUserTaxFree()) {
|
||||||
Yard::setTax($cartItem->rowId, 0);
|
Yard::setTax($cartItem->rowId, 0);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -77,12 +85,12 @@ class CardController extends Controller
|
||||||
|
|
||||||
\Session()->flash('show-card-after-add', true);
|
\Session()->flash('show-card-after-add', true);
|
||||||
}
|
}
|
||||||
|
|
||||||
return back();
|
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'));
|
Yard::instance($this->instance)->setShippingCountryWithPrice(Request::get('selected_country'));
|
||||||
|
|
@ -98,24 +106,43 @@ class CardController extends Controller
|
||||||
'yard_instance' => $this->instance,
|
'yard_instance' => $this->instance,
|
||||||
'shipping_error' => $shipping_error ?? false,
|
'shipping_error' => $shipping_error ?? false,
|
||||||
];
|
];
|
||||||
|
|
||||||
return view('web.templates.card', $data);
|
return view('web.templates.card', $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updateCard(){
|
public function updateCard()
|
||||||
|
{
|
||||||
|
|
||||||
$data = Request::all();
|
$data = Request::all();
|
||||||
if (isset($data['quantity'])) {
|
if (isset($data['quantity'])) {
|
||||||
foreach ($data['quantity'] as $rowId => $qty) {
|
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)->update($rowId, $qty);
|
||||||
Yard::instance($this->instance)->reCalculateShippingPrice();
|
Yard::instance($this->instance)->reCalculateShippingPrice();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$this->deleteCard();
|
$this->deleteCard();
|
||||||
}
|
}
|
||||||
|
|
||||||
return back();
|
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();
|
$user_shop = Util::getUserShop();
|
||||||
|
|
||||||
|
|
@ -145,33 +172,41 @@ class CardController extends Controller
|
||||||
if (strpos($path, 'https') === false) {
|
if (strpos($path, 'https') === false) {
|
||||||
$path = str_replace('http', 'https', $path);
|
$path = str_replace('http', 'https', $path);
|
||||||
}
|
}
|
||||||
|
|
||||||
return redirect()->secure($path);
|
return redirect()->secure($path);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function backToShop(){
|
public function backToShop()
|
||||||
|
{
|
||||||
$this->deleteCard();
|
$this->deleteCard();
|
||||||
return redirect(url('/'));
|
|
||||||
|
|
||||||
|
return redirect(url('/'));
|
||||||
}
|
}
|
||||||
public function removeCard($rowId){
|
|
||||||
|
public function removeCard($rowId)
|
||||||
|
{
|
||||||
|
|
||||||
Yard::instance($this->instance)->remove($rowId);
|
Yard::instance($this->instance)->remove($rowId);
|
||||||
|
|
||||||
return back();
|
return back();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deleteCard(){
|
public function deleteCard()
|
||||||
|
{
|
||||||
|
|
||||||
$setCode = Shop::getUserShopLang(null, $this->instance);
|
$setCode = Shop::getUserShopLang(null, $this->instance);
|
||||||
$mylangs = Shop::getLangChange($this->instance);
|
$mylangs = Shop::getLangChange($this->instance);
|
||||||
foreach ($mylangs as $code => $country) {
|
foreach ($mylangs as $code => $country) {
|
||||||
if (strtolower($setCode) === strtolower($code)) {
|
if (strtolower($setCode) === strtolower($code)) {
|
||||||
Shop::initUserShopLang($country, $this->instance);
|
Shop::initUserShopLang($country, $this->instance);
|
||||||
|
|
||||||
return back();
|
return back();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function checkShippingError(){
|
private function checkShippingError()
|
||||||
|
{
|
||||||
$shipping_error = false;
|
$shipping_error = false;
|
||||||
if (\Auth::guard('customers')->check()) {
|
if (\Auth::guard('customers')->check()) {
|
||||||
$user = \Auth::guard('customers')->user();
|
$user = \Auth::guard('customers')->user();
|
||||||
|
|
@ -194,9 +229,9 @@ class CardController extends Controller
|
||||||
$shipping_error = __('website.shipping_error_delivery', ['shipping_country' => $user_country_name, 'billing_country' => $country_name]);
|
$shipping_error = __('website.shipping_error_delivery', ['shipping_country' => $user_country_name, 'billing_country' => $country_name]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $shipping_error;
|
return $shipping_error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2,20 +2,18 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers\Web;
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
use Yard;
|
use App\Models\Category;
|
||||||
use Request;
|
|
||||||
use App\Models\IqSite;
|
use App\Models\IqSite;
|
||||||
|
use App\Models\Product;
|
||||||
|
use App\Models\ProductCategory;
|
||||||
|
use App\Services\LocaleGuard;
|
||||||
use App\Services\Shop;
|
use App\Services\Shop;
|
||||||
use App\Services\Util;
|
use App\Services\Util;
|
||||||
use App\Models\Product;
|
use Request;
|
||||||
use App\Models\Category;
|
|
||||||
use App\Models\ProductCategory;
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
|
|
||||||
class SiteController extends Controller
|
class SiteController extends Controller
|
||||||
{
|
{
|
||||||
|
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
$this->setIPInfo();
|
$this->setIPInfo();
|
||||||
|
|
@ -36,7 +34,7 @@ class SiteController extends Controller
|
||||||
|
|
||||||
public function domainCheck()
|
public function domainCheck()
|
||||||
{
|
{
|
||||||
die("checked");
|
exit('checked');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function changeLang()
|
public function changeLang()
|
||||||
|
|
@ -48,8 +46,12 @@ class SiteController extends Controller
|
||||||
if (strtolower($data['change_country_id']) === strtolower($code)) {
|
if (strtolower($data['change_country_id']) === strtolower($code)) {
|
||||||
\Session::put('user_init_country', strtolower($code));
|
\Session::put('user_init_country', strtolower($code));
|
||||||
\Session::forget('user_init_country_options');
|
\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');
|
Shop::initUserShopLang($country, 'webshop');
|
||||||
|
|
||||||
return back();
|
return back();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -67,6 +69,7 @@ class SiteController extends Controller
|
||||||
$country = strtolower(Shop::getIPDatabaseInfo());
|
$country = strtolower(Shop::getIPDatabaseInfo());
|
||||||
if ($country === 'de') { // $locale de - init AT
|
if ($country === 'de') { // $locale de - init AT
|
||||||
\Session::put('user_init_country', $country);
|
\Session::put('user_init_country', $country);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ($country === 'error') { // $locale at - init AT
|
if ($country === 'error') { // $locale at - init AT
|
||||||
|
|
@ -119,6 +122,7 @@ class SiteController extends Controller
|
||||||
'p_count' => Product::where('active', true)->whereJsonContains('show_on', '1')->count(),
|
'p_count' => Product::where('active', true)->whereJsonContains('show_on', '1')->count(),
|
||||||
'yard_instance' => 'webshop',
|
'yard_instance' => 'webshop',
|
||||||
];
|
];
|
||||||
|
|
||||||
return view('web.templates.produkte-show', $data);
|
return view('web.templates.produkte-show', $data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -131,7 +135,7 @@ class SiteController extends Controller
|
||||||
$headline_image = $category->iq_image;
|
$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');
|
$query->where('active', true)->whereJsonContains('show_on', '1');
|
||||||
})->orderBy('pos', 'DESC')->get();
|
})->orderBy('pos', 'DESC')->get();
|
||||||
|
|
||||||
|
|
@ -147,6 +151,7 @@ class SiteController extends Controller
|
||||||
'headline_image' => $headline_image,
|
'headline_image' => $headline_image,
|
||||||
'yard_instance' => 'webshop',
|
'yard_instance' => 'webshop',
|
||||||
];
|
];
|
||||||
|
|
||||||
return view('web.templates.'.$site, $data);
|
return view('web.templates.'.$site, $data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -163,6 +168,7 @@ class SiteController extends Controller
|
||||||
'headline_image' => false,
|
'headline_image' => false,
|
||||||
'yard_instance' => 'webshop',
|
'yard_instance' => 'webshop',
|
||||||
];
|
];
|
||||||
|
|
||||||
return view('web.templates.'.$site, $data);
|
return view('web.templates.'.$site, $data);
|
||||||
}
|
}
|
||||||
$data = [
|
$data = [
|
||||||
|
|
@ -174,11 +180,13 @@ class SiteController extends Controller
|
||||||
if (! view()->exists('web.templates.'.$subsite)) {
|
if (! view()->exists('web.templates.'.$subsite)) {
|
||||||
abort(404);
|
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);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
return view('web.templates.'.$site, $data);
|
return view('web.templates.'.$site, $data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -605,6 +605,9 @@ class WizardController extends Controller
|
||||||
$image = $product->images->first()->slug;
|
$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]);
|
$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()) {
|
if (\App\Services\UserService::getTaxFree()) {
|
||||||
Yard::setTax($cartItem->rowId, 0);
|
Yard::setTax($cartItem->rowId, 0);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -2,29 +2,37 @@
|
||||||
|
|
||||||
namespace App\Http\Middleware;
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
use Carbon;
|
use App\Services\LocaleGuard;
|
||||||
use Closure;
|
use Closure;
|
||||||
use Auth;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\App;
|
||||||
|
use Illuminate\Support\Facades\Session;
|
||||||
|
|
||||||
class Localization
|
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(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||||
* @param \Closure $next
|
|
||||||
* @return mixed
|
|
||||||
*/
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): mixed
|
||||||
public function handle($request, Closure $next)
|
|
||||||
{
|
{
|
||||||
if (\Session::has('locale')) {
|
if (! Session::has('locale')) {
|
||||||
\App::setLocale(\Session::get('locale'));
|
return $next($request);
|
||||||
// Carbon::setLocale('\Session::get('locale')');
|
|
||||||
//Carbon::setLocale('de');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$raw = Session::get('locale');
|
||||||
|
$normalized = LocaleGuard::normalize(is_string($raw) ? $raw : null);
|
||||||
|
|
||||||
|
if ($normalized !== null) {
|
||||||
|
App::setLocale($normalized);
|
||||||
|
|
||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Session::forget('locale');
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
30
app/Models/AboChartSnapshot.php
Normal file
30
app/Models/AboChartSnapshot.php
Normal 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
240
app/Models/Incentive.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
56
app/Models/IncentiveNewAbo.php
Normal file
56
app/Models/IncentiveNewAbo.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
57
app/Models/IncentiveNewPartner.php
Normal file
57
app/Models/IncentiveNewPartner.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
536
app/Models/IncentiveParticipant.php
Normal file
536
app/Models/IncentiveParticipant.php
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
148
app/Models/IncentivePointsLog.php
Normal file
148
app/Models/IncentivePointsLog.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
|
@ -339,6 +340,36 @@ class ShoppingOrder extends Model
|
||||||
return $this->hasMany('App\Models\ShoppingOrderItem', 'shopping_order_id');
|
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()
|
public function shopping_payments()
|
||||||
{
|
{
|
||||||
return $this->hasMany('App\Models\ShoppingPayment', 'shopping_order_id');
|
return $this->hasMany('App\Models\ShoppingPayment', 'shopping_order_id');
|
||||||
|
|
|
||||||
|
|
@ -256,11 +256,23 @@ class UserAbo extends Model
|
||||||
return $this->attributes['cancel_date'] ? Carbon::parse($this->attributes['cancel_date'])->format(\Util::formatDateDB()) : '';
|
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) : '';
|
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()
|
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>';
|
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>';
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,10 @@ use App\Services\AboHelper;
|
||||||
|
|
||||||
class AboRepository extends BaseRepository
|
class AboRepository extends BaseRepository
|
||||||
{
|
{
|
||||||
|
private const LOCK_DAYS_CHANGE = 10;
|
||||||
|
|
||||||
|
private const LOCK_DAYS_PAUSE_CANCEL = 3;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
// $this->model = $model;
|
// $this->model = $model;
|
||||||
|
|
@ -24,9 +28,12 @@ class AboRepository extends BaseRepository
|
||||||
if ($this->validate($data)) {
|
if ($this->validate($data)) {
|
||||||
$this->updateStatus($data);
|
$this->updateStatus($data);
|
||||||
$this->model->abo_interval = $data['abo_interval'];
|
$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();
|
$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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -44,6 +51,16 @@ class AboRepository extends BaseRepository
|
||||||
{
|
{
|
||||||
// Handle cancellation
|
// Handle cancellation
|
||||||
if (isset($data['abo_cancel']) && $data['abo_cancel'] == 'true') {
|
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)
|
// Status 4 = abo_cancel (storniert/gekündigt)
|
||||||
$this->model->status = 4;
|
$this->model->status = 4;
|
||||||
$this->model->active = false;
|
$this->model->active = false;
|
||||||
|
|
@ -54,6 +71,15 @@ class AboRepository extends BaseRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
$active = (isset($data['abo_is_active']) && $data['abo_is_active']) ? true : false;
|
$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 status is active and active is false, set status to inactive
|
||||||
if ($this->model->active && ! $active) {
|
if ($this->model->active && ! $active) {
|
||||||
if ($this->model->status == 2) { // okay
|
if ($this->model->status == 2) { // okay
|
||||||
|
|
@ -63,7 +89,7 @@ class AboRepository extends BaseRepository
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (! $this->model->active && $active) {
|
if (! $this->model->active && $active) {
|
||||||
if ($this->model->status = 6) { // inactive
|
if ($this->model->status == 6) { // inactive
|
||||||
$this->model->status = 2; // okay
|
$this->model->status = 2; // okay
|
||||||
$this->model->active = true;
|
$this->model->active = true;
|
||||||
$this->model->save();
|
$this->model->save();
|
||||||
|
|
@ -97,23 +123,51 @@ class AboRepository extends BaseRepository
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (! in_array($data['abo_interval'], \App\Models\UserAbo::$aboDeliveryDays)) {
|
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'));
|
\Session()->flash('alert-error', __('abo.error_abo_interval'));
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prüfung: Wenn das Abo diesen Monat noch nicht ausgeführt wurde (oder noch nie),
|
// Sperre: 10 Tage vor nächster Ausführung keine Änderungen mehr (Pakete werden vorgepackt)
|
||||||
// darf das Abo-Intervall nicht auf einen Tag gesetzt werden, der bereits vergangen ist (oder heute ist),
|
if ($this->model->next_date) {
|
||||||
// da setNextDate das nächste Ausführungsdatum sonst auf den nächsten Monat setzt und dieser Monat übersprungen wird.
|
$daysUntilExecution = (int) now()->diffInDays(\Carbon\Carbon::parse($this->model->next_date), false);
|
||||||
$executedThisMonth = $this->model->last_date && \Carbon\Carbon::parse($this->model->last_date)->isCurrentMonth();
|
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) {
|
return false;
|
||||||
\Session()->flash('alert-error', __('abo.error_abo_interval_in_the_past'));
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,10 @@ use App\Models\ShoppingOrder;
|
||||||
use App\Models\UserInvoice;
|
use App\Models\UserInvoice;
|
||||||
use App\Models\UserSalesVolume;
|
use App\Models\UserSalesVolume;
|
||||||
use App\Services\BusinessPlan\SalesPointsVolume;
|
use App\Services\BusinessPlan\SalesPointsVolume;
|
||||||
|
use App\Services\Incentive\IncentiveTracker;
|
||||||
use App\Services\Invoice;
|
use App\Services\Invoice;
|
||||||
use App\Services\UserService;
|
use App\Services\UserService;
|
||||||
|
use App\Services\Util;
|
||||||
use Storage;
|
use Storage;
|
||||||
|
|
||||||
class InvoiceRepository extends BaseRepository
|
class InvoiceRepository extends BaseRepository
|
||||||
|
|
@ -217,12 +219,17 @@ class InvoiceRepository extends BaseRepository
|
||||||
|
|
||||||
public function createAndSalesVolume($request = [])
|
public function createAndSalesVolume($request = [])
|
||||||
{
|
{
|
||||||
$this->user_sales_volume = SalesPointsVolume::addSalesPointsVolumeUser($this->model);
|
$this->user_sales_volume = SalesPointsVolume::User($this->model);
|
||||||
|
if (! Util::isTestSystem(true)) { // rechnung erstellen nur in production
|
||||||
$user_invoice = $this->create($request);
|
$user_invoice = $this->create($request);
|
||||||
$this->user_sales_volume->user_invoice_id = $user_invoice->id;
|
$this->user_sales_volume->user_invoice_id = $user_invoice->id;
|
||||||
$this->user_sales_volume->save();
|
$this->user_sales_volume->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Incentive: Track sales volume points
|
||||||
|
IncentiveTracker::trackSalesVolume($this->user_sales_volume);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Erstellt eine Stornorechnung mit Punktekorrektur
|
* Erstellt eine Stornorechnung mit Punktekorrektur
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,19 @@ use App\Models\ShoppingPayment;
|
||||||
use App\Models\ShoppingUser;
|
use App\Models\ShoppingUser;
|
||||||
use App\Models\UserAbo;
|
use App\Models\UserAbo;
|
||||||
use App\Models\UserAboItem;
|
use App\Models\UserAboItem;
|
||||||
|
use App\Models\UserAboItemHistory;
|
||||||
use App\Models\UserAboOrder;
|
use App\Models\UserAboOrder;
|
||||||
|
use App\Services\Incentive\IncentiveTracker;
|
||||||
use App\User;
|
use App\User;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
|
|
||||||
class AboHelper
|
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 = [
|
public static $txaction_filter_text = [
|
||||||
'paid' => 'paymend_paid',
|
'paid' => 'paymend_paid',
|
||||||
'appointed' => 'paymend_open',
|
'appointed' => 'paymend_open',
|
||||||
|
|
@ -50,9 +57,19 @@ class AboHelper
|
||||||
public static function setAboStatus(ShoppingOrder $shopping_order, $status, $paid = false)
|
public static function setAboStatus(ShoppingOrder $shopping_order, $status, $paid = false)
|
||||||
{
|
{
|
||||||
$user_abo = $shopping_order->getUserAbo();
|
$user_abo = $shopping_order->getUserAbo();
|
||||||
if ($user_abo && $user_abo->status < 2) { // status < 2 is not active
|
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]);
|
$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]);
|
UserAboOrder::where('user_abo_id', $user_abo->id)->where('shopping_order_id', $shopping_order->id)->update(['status' => $status, 'paid' => $paid]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -153,38 +170,95 @@ class AboHelper
|
||||||
|
|
||||||
public static function getFirstAboDate($date, $abo_interval)
|
public static function getFirstAboDate($date, $abo_interval)
|
||||||
{
|
{
|
||||||
$nextDate = Carbon::parse($date)->firstOfMonth()->addMonth(1);
|
$reference = Carbon::parse($date)->startOfDay();
|
||||||
$nextDate->addDays($abo_interval - 1);
|
$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)
|
public static function createNewAbo(ShoppingPayment $shopping_payment)
|
||||||
{
|
{
|
||||||
// is Abo - create init Abo from PP or else
|
$order = $shopping_payment->shopping_order;
|
||||||
if ($shopping_payment->shopping_order->is_abo && $shopping_payment->shopping_order->abo_interval > 0) {
|
if (! $order || ! $order->is_abo || (int) $order->abo_interval <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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();
|
$payment_transaction = $shopping_payment->payment_transactions->last();
|
||||||
|
$payoneUserId = $payment_transaction ? (int) $payment_transaction->userid : 0;
|
||||||
|
|
||||||
// next_date immer im nächsten Monat starten
|
// next_date immer im nächsten Monat starten
|
||||||
// is auth_user_id = Berater bestellung
|
// is auth_user_id = Berater bestellung
|
||||||
// is member_id = Kunden bestellung
|
// is member_id = Kunden bestellung
|
||||||
// is for = me = mich oder ot = kunde
|
// is for = me = mich oder ot = kunde
|
||||||
$user_abo = UserAbo::create([
|
$user_abo = UserAbo::create([
|
||||||
'user_id' => $shopping_payment->shopping_order->auth_user_id,
|
'user_id' => $order->auth_user_id,
|
||||||
'member_id' => $shopping_payment->shopping_order->member_id,
|
'member_id' => $order->member_id,
|
||||||
'shopping_user_id' => $shopping_payment->shopping_order->shopping_user_id,
|
'shopping_user_id' => $order->shopping_user_id,
|
||||||
'email' => $shopping_payment->shopping_order->shopping_user->billing_email,
|
'email' => $order->shopping_user->billing_email,
|
||||||
'is_for' => $shopping_payment->shopping_order->shopping_user->is_for,
|
'is_for' => $order->shopping_user->is_for,
|
||||||
'payone_userid' => $payment_transaction->userid,
|
'payone_userid' => $payoneUserId,
|
||||||
'clearingtype' => $shopping_payment->clearingtype,
|
'clearingtype' => $shopping_payment->clearingtype,
|
||||||
'wallettype' => $shopping_payment->wallettype,
|
'wallettype' => $shopping_payment->wallettype,
|
||||||
'carddata' => $shopping_payment->carddata,
|
'carddata' => $shopping_payment->carddata,
|
||||||
'amount' => $shopping_payment->amount,
|
'amount' => $shopping_payment->amount,
|
||||||
'status' => 1,
|
'status' => 1,
|
||||||
'abo_interval' => $shopping_payment->abo_interval,
|
'abo_interval' => $aboInterval,
|
||||||
'start_date' => now(),
|
'start_date' => now(),
|
||||||
'last_date' => now(),
|
'last_date' => now(),
|
||||||
'next_date' => self::getFirstAboDate(now(), $shopping_payment->abo_interval),
|
'next_date' => self::getFirstAboDate(now(), $aboInterval),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($user_abo) {
|
if ($user_abo) {
|
||||||
|
|
@ -194,6 +268,13 @@ class AboHelper
|
||||||
'shopping_order_id' => $shopping_payment->shopping_order_id,
|
'shopping_order_id' => $shopping_payment->shopping_order_id,
|
||||||
'status' => 1,
|
'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');
|
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()
|
public static function getTransStatusFilterText()
|
||||||
{
|
{
|
||||||
$ret = [];
|
$ret = [];
|
||||||
|
|
@ -287,4 +419,79 @@ class AboHelper
|
||||||
|
|
||||||
return array_values(array_unique($teamUserIds));
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,8 @@ class AboOrderCart
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AboHelper::ensureUserAboItemsFromLatestOrder($user_abo);
|
||||||
|
|
||||||
// Sicherstellen, dass die Items für dieses spezifische Abo geladen werden
|
// Sicherstellen, dass die Items für dieses spezifische Abo geladen werden
|
||||||
// Verwende fresh() um sicherzustellen, dass wir die aktuellen Daten haben
|
// Verwende fresh() um sicherzustellen, dass wir die aktuellen Daten haben
|
||||||
$abo_items = $user_abo->user_abo_items()->get();
|
$abo_items = $user_abo->user_abo_items()->get();
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,32 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Services\BusinessPlan;
|
namespace App\Services\BusinessPlan;
|
||||||
|
|
||||||
use App\User;
|
|
||||||
use stdClass;
|
|
||||||
use Carbon\Carbon;
|
|
||||||
use App\Models\UserLevel;
|
|
||||||
use App\Models\UserBusiness;
|
use App\Models\UserBusiness;
|
||||||
use App\Services\TranslationHelper;
|
use App\Models\UserLevel;
|
||||||
use App\Models\UserBusinessStructure;
|
use App\User;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use stdClass;
|
||||||
|
|
||||||
class BusinessUserItem
|
class BusinessUserItem
|
||||||
{
|
{
|
||||||
public $businessUserItems = [];
|
public $businessUserItems = [];
|
||||||
|
|
||||||
private $date;
|
private $date;
|
||||||
|
|
||||||
private $b_user;
|
private $b_user;
|
||||||
|
|
||||||
private $user_level_active_pos;
|
private $user_level_active_pos;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
public function __construct($date)
|
public function __construct($date)
|
||||||
{
|
{
|
||||||
$this->date = $date;
|
$this->date = $date;
|
||||||
|
|
||||||
return $this;
|
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();
|
$this->b_user = UserBusiness::where('user_id', $user_id)->where('month', $this->date->month)->where('year', $this->date->year)->first();
|
||||||
|
|
@ -40,7 +40,7 @@ class BusinessUserItem
|
||||||
}
|
}
|
||||||
$user_level_active = $user->user_level ? $user->user_level : null;
|
$user_level_active = $user->user_level ? $user->user_level : null;
|
||||||
$this->user_level_active_pos = $user_level_active ? $user_level_active->pos : 0;
|
$this->user_level_active_pos = $user_level_active ? $user_level_active->pos : 0;
|
||||||
$this->b_user = new UserBusiness();
|
$this->b_user = new UserBusiness;
|
||||||
$fill = [
|
$fill = [
|
||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
'month' => $this->date->month,
|
'month' => $this->date->month,
|
||||||
|
|
@ -48,8 +48,8 @@ class BusinessUserItem
|
||||||
'm_level_id' => $user->m_level,
|
'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,
|
'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,
|
'payment_account_date' => $user->payment_account ? $user->getPaymentAccountDateFormat(false) : null,
|
||||||
'active_date' => $user->active_date ? $user->active_date : NULL,
|
'active_date' => $user->active_date ? $user->active_date : null,
|
||||||
'm_account' => $user->account->m_account,
|
'm_account' => $user->account->m_account,
|
||||||
'email' => $user->email,
|
'email' => $user->email,
|
||||||
'first_name' => $user->account->first_name,
|
'first_name' => $user->account->first_name,
|
||||||
|
|
@ -86,80 +86,97 @@ class BusinessUserItem
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getSalesVolumeTotalMargin()
|
||||||
public function getSalesVolumeTotalMargin(){
|
{
|
||||||
return $this->b_user->getSalesVolumeTotalMargin();
|
return $this->b_user->getSalesVolumeTotalMargin();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function addUserID(){
|
public function addUserID()
|
||||||
|
{
|
||||||
TreeCalcBot::addUserID($this->b_user->user_id);
|
TreeCalcBot::addUserID($this->b_user->user_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getBUser(){
|
public function getBUser()
|
||||||
|
{
|
||||||
return $this->b_user;
|
return $this->b_user;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function addBusinessLineToUser($line, $obj){
|
public function addBusinessLineToUser($line, $obj)
|
||||||
|
{
|
||||||
$this->b_user->business_lines[$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 = $this->business_lines[$line];
|
||||||
$obj->points += $points;
|
$obj->points += $points;
|
||||||
$this->b_user->business_lines[$line] = $obj;
|
$this->b_user->business_lines[$line] = $obj;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function addTotalTP($points){
|
public function addTotalTP($points)
|
||||||
|
{
|
||||||
$this->b_user->total_pp += $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;
|
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;
|
return ($this->qual_user_level) ? true : false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isQualEqualLevel(){
|
public function isQualEqualLevel()
|
||||||
|
{
|
||||||
if ($this->qual_user_level) {
|
if ($this->qual_user_level) {
|
||||||
return ($this->m_level_id == $this->qual_user_level['id']) ? true : false;
|
return ($this->m_level_id == $this->qual_user_level['id']) ? true : false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getQualLevelPaylines(){
|
public function getQualLevelPaylines()
|
||||||
|
{
|
||||||
if ($this->qual_user_level) {
|
if ($this->qual_user_level) {
|
||||||
return $this->qual_user_level['paylines'];
|
return $this->qual_user_level['paylines'];
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isQualLevelGrowth($line){
|
public function isQualLevelGrowth($line)
|
||||||
|
{
|
||||||
if (isset($this->business_lines[$line])) {
|
if (isset($this->business_lines[$line])) {
|
||||||
$object = $this->business_lines[$line];
|
$object = $this->business_lines[$line];
|
||||||
if (isset($object->growth_bonus)) {
|
if (isset($object->growth_bonus)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getRestQualKP()
|
||||||
public function getRestQualKP(){
|
{
|
||||||
$ret = $this->sales_volume_points_KP_sum - $this->qual_kp;
|
$ret = $this->sales_volume_points_KP_sum - $this->qual_kp;
|
||||||
|
|
||||||
return $ret > 0 ? $ret : 0;
|
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);
|
return round($this->commission_shop_sales + $this->commission_pp_total + $this->commission_growth_total, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provisierungslevel brechnen, Berechnung der Provisionen nach Level
|
// Provisierungslevel brechnen, Berechnung der Provisionen nach Level
|
||||||
public function calcQualPP(){
|
public function calcQualPP()
|
||||||
|
{
|
||||||
|
|
||||||
// das ist der erreichte Provisierungslevel, nach paylinePoints und KP
|
// das ist der erreichte Provisierungslevel, nach paylinePoints und KP
|
||||||
$qualUserLevel = $this->calcuQualLevel();
|
$qualUserLevel = $this->calcuQualLevel();
|
||||||
if($qualUserLevel !== NULL){
|
if ($qualUserLevel !== null) {
|
||||||
// prüfe einen Aufsieg im KarriereLevel
|
// prüfe einen Aufsieg im KarriereLevel
|
||||||
$this->setNextUserLevel();
|
$this->setNextUserLevel();
|
||||||
$this->b_user->qual_user_level = $qualUserLevel->toArray();
|
$this->b_user->qual_user_level = $qualUserLevel->toArray();
|
||||||
|
|
@ -208,11 +225,11 @@ class BusinessUserItem
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// qualifikation nach qual_kp (KundenPoints) und qual_pp (PaylinePoints)
|
// qualifikation nach qual_kp (KundenPoints) und qual_pp (PaylinePoints)
|
||||||
public function calcuQualLevel(){
|
public function calcuQualLevel()
|
||||||
|
{
|
||||||
// alle levels wo die qual_kp erreicht ist, sortiert nach Rang >
|
// 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();
|
$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) {
|
foreach ($qualUserLevels as $qualUserLevel) {
|
||||||
|
|
@ -227,22 +244,26 @@ class BusinessUserItem
|
||||||
return $qualUserLevel;
|
return $qualUserLevel;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return NULL;
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// PaylinePoints nach paylines / welche ebenen gezählt werden, 3,4,5,6 ...
|
// PaylinePoints nach paylines / welche ebenen gezählt werden, 3,4,5,6 ...
|
||||||
private function getPointsforPayline($paylines){
|
private function getPointsforPayline($paylines)
|
||||||
|
{
|
||||||
$payline_points = 0;
|
$payline_points = 0;
|
||||||
for ($i = 1; $i <= $paylines; $i++) {
|
for ($i = 1; $i <= $paylines; $i++) {
|
||||||
if (isset($this->business_lines[$i])) {
|
if (isset($this->business_lines[$i])) {
|
||||||
$payline_points += $this->business_lines[$i]->points;
|
$payline_points += $this->business_lines[$i]->points;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $payline_points;
|
return $payline_points;
|
||||||
}
|
}
|
||||||
|
|
||||||
// wenn nicht erreicht, was wäre der nächste Provisionslevel?
|
// wenn nicht erreicht, was wäre der nächste Provisionslevel?
|
||||||
private function setQualNextLevel(){
|
private function setQualNextLevel()
|
||||||
|
{
|
||||||
if (! $this->isQualEqualLevel()) {
|
if (! $this->isQualEqualLevel()) {
|
||||||
$qualUserLevelNext = UserLevel::where('id', '=', $this->b_user->qual_user_level['next_id'])->orderBy('qual_pp', 'asc')->first();
|
$qualUserLevelNext = UserLevel::where('id', '=', $this->b_user->qual_user_level['next_id'])->orderBy('qual_pp', 'asc')->first();
|
||||||
if ($qualUserLevelNext) {
|
if ($qualUserLevelNext) {
|
||||||
|
|
@ -252,7 +273,8 @@ class BusinessUserItem
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function setNextUserLevel(){
|
private function setNextUserLevel()
|
||||||
|
{
|
||||||
// $this->b_user->payline_points_qual_kp // das sind die Payline Points + Rest KP
|
// $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
|
||||||
// $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
|
||||||
|
|
@ -291,16 +313,18 @@ class BusinessUserItem
|
||||||
return $ret;
|
return $ret;
|
||||||
}*/
|
}*/
|
||||||
|
|
||||||
public function readParentsBusinessUsers(){
|
public function readParentsBusinessUsers()
|
||||||
|
{
|
||||||
|
|
||||||
$users = User::with('account')->select('users.*')
|
$users = User::with('account')->select('users.*')
|
||||||
->where('users.deleted_at', '=', null)
|
->where('users.deleted_at', '=', null)
|
||||||
->where('users.id', '!=', 1)
|
->where('users.id', '!=', 1)
|
||||||
->where('users.admin', "<", 4)
|
->where('users.admin', '<', 4)
|
||||||
->where('users.m_level', "!=", null)
|
->where('users.m_level', '!=', null)
|
||||||
->where('users.m_sponsor', "=", $this->b_user->user_id) //<- need the id for parents / sponsors
|
->whereColumn('users.id', '!=', 'users.m_sponsor')
|
||||||
->where('users.payment_account', "!=", null)
|
->where('users.m_sponsor', '=', $this->b_user->user_id) // <- need the id for parents / sponsors
|
||||||
->where('users.active_date', "<=", $this->date->end_date) // wurde in dem Monat freigeschaltet
|
->where('users.payment_account', '!=', null)
|
||||||
|
->where('users.active_date', '<=', $this->date->end_date) // wurde in dem Monat freigeschaltet
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
if ($users) {
|
if ($users) {
|
||||||
|
|
@ -316,7 +340,8 @@ class BusinessUserItem
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function readStoredParentsBusinessUsers($structure){
|
public function readStoredParentsBusinessUsers($structure)
|
||||||
|
{
|
||||||
|
|
||||||
$parents = $this->findParentsBusinessOnStored($this->b_user->user_id, $structure);
|
$parents = $this->findParentsBusinessOnStored($this->b_user->user_id, $structure);
|
||||||
if ($parents) {
|
if ($parents) {
|
||||||
|
|
@ -332,7 +357,8 @@ class BusinessUserItem
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function findParentsBusinessOnStored($user_id, $structures){
|
private function findParentsBusinessOnStored($user_id, $structures)
|
||||||
|
{
|
||||||
if ($structures) {
|
if ($structures) {
|
||||||
foreach ($structures as $obj) {
|
foreach ($structures as $obj) {
|
||||||
if ($user_id === $obj->user_id) {
|
if ($user_id === $obj->user_id) {
|
||||||
|
|
@ -345,16 +371,18 @@ class BusinessUserItem
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function checkSponsor($user){
|
public function checkSponsor($user)
|
||||||
|
{
|
||||||
|
|
||||||
// check is store? has ID
|
// check is store? has ID
|
||||||
if ($this->b_user->isSave()) {
|
if ($this->b_user->isSave()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$sponsor = new stdClass();
|
$sponsor = new stdClass;
|
||||||
|
|
||||||
$sponsor->is_sponsor = false;
|
$sponsor->is_sponsor = false;
|
||||||
$sponsor->user_id = false;
|
$sponsor->user_id = false;
|
||||||
|
|
@ -383,14 +411,16 @@ class BusinessUserItem
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$this->b_user->sponsor = $sponsor;
|
$this->b_user->sponsor = $sponsor;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isSave(){
|
public function isSave()
|
||||||
|
{
|
||||||
return $this->b_user->isSave();
|
return $this->b_user->isSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function __get($property) {
|
public function __get($property)
|
||||||
|
{
|
||||||
if ($this->b_user === null) {
|
if ($this->b_user === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -401,5 +431,4 @@ class BusinessUserItem
|
||||||
return $this->b_user->{$property};
|
return $this->b_user->{$property};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,13 @@
|
||||||
|
|
||||||
namespace App\Services\BusinessPlan;
|
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\UserAccount;
|
||||||
|
use App\Models\UserBusiness;
|
||||||
|
use App\Models\UserLevel;
|
||||||
|
use App\User;
|
||||||
|
use Carbon\Carbon;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use stdClass;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optimierte Version der BusinessUserItem Klasse
|
* Optimierte Version der BusinessUserItem Klasse
|
||||||
|
|
@ -25,10 +24,15 @@ class BusinessUserItemOptimized
|
||||||
public $businessUserItems = [];
|
public $businessUserItems = [];
|
||||||
|
|
||||||
private $date;
|
private $date;
|
||||||
|
|
||||||
private $b_user;
|
private $b_user;
|
||||||
|
|
||||||
private ?TreeCalcBotOptimized $treeCalcBot = null;
|
private ?TreeCalcBotOptimized $treeCalcBot = null;
|
||||||
|
|
||||||
private $user_level_active_pos;
|
private $user_level_active_pos;
|
||||||
|
|
||||||
private $needsQualificationRecalculation = false;
|
private $needsQualificationRecalculation = false;
|
||||||
|
|
||||||
private $qualificationCalculated = false;
|
private $qualificationCalculated = false;
|
||||||
|
|
||||||
public function __construct($date, ?TreeCalcBotOptimized $treeCalcBot = null)
|
public function __construct($date, ?TreeCalcBotOptimized $treeCalcBot = null)
|
||||||
|
|
@ -36,6 +40,7 @@ class BusinessUserItemOptimized
|
||||||
$this->date = $date;
|
$this->date = $date;
|
||||||
$this->treeCalcBot = $treeCalcBot;
|
$this->treeCalcBot = $treeCalcBot;
|
||||||
$this->businessUserItems = []; // Initialize array
|
$this->businessUserItems = []; // Initialize array
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -44,7 +49,6 @@ class BusinessUserItemOptimized
|
||||||
return $this->qualificationCalculated;
|
return $this->qualificationCalculated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Erstellt BusinessUser aus User-ID (Original-Methode für Rückwärtskompatibilität)
|
* Erstellt BusinessUser aus User-ID (Original-Methode für Rückwärtskompatibilität)
|
||||||
*
|
*
|
||||||
|
|
@ -87,6 +91,7 @@ class BusinessUserItemOptimized
|
||||||
|
|
||||||
if (! $user) {
|
if (! $user) {
|
||||||
\Log::warning("BusinessUserItem: User not found: {$user_id}");
|
\Log::warning("BusinessUserItem: User not found: {$user_id}");
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -170,7 +175,7 @@ class BusinessUserItemOptimized
|
||||||
$this->user_level_active_pos = $user_level_active ? $user_level_active->pos : 0;
|
$this->user_level_active_pos = $user_level_active ? $user_level_active->pos : 0;
|
||||||
|
|
||||||
// Neues UserBusiness Objekt erstellen
|
// Neues UserBusiness Objekt erstellen
|
||||||
$this->b_user = new UserBusiness();
|
$this->b_user = new UserBusiness;
|
||||||
|
|
||||||
// Account-Daten (mit intelligentem Laden und Error-Handling)
|
// Account-Daten (mit intelligentem Laden und Error-Handling)
|
||||||
$account = $this->getAccountForUser($user);
|
$account = $this->getAccountForUser($user);
|
||||||
|
|
@ -230,7 +235,7 @@ class BusinessUserItemOptimized
|
||||||
$this->b_user->commission_shop_sales = $calculatedCommission;
|
$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("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));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -289,7 +294,7 @@ class BusinessUserItemOptimized
|
||||||
'sales_volume_points_TP_sum',
|
'sales_volume_points_TP_sum',
|
||||||
'sales_volume_total',
|
'sales_volume_total',
|
||||||
'sales_volume_total_shop',
|
'sales_volume_total_shop',
|
||||||
'sales_volume_total_sum'
|
'sales_volume_total_sum',
|
||||||
];
|
];
|
||||||
|
|
||||||
$needsUpdate = false;
|
$needsUpdate = false;
|
||||||
|
|
@ -363,6 +368,7 @@ class BusinessUserItemOptimized
|
||||||
return Carbon::parse($user->payment_account)->gt(Carbon::now());
|
return Carbon::parse($user->payment_account)->gt(Carbon::now());
|
||||||
} catch (\Exception $e) {
|
} 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;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -405,6 +411,7 @@ class BusinessUserItemOptimized
|
||||||
return $value;
|
return $value;
|
||||||
} catch (\Exception $e) {
|
} 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
|
return 0; // Sicherer Fallback
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -422,7 +429,7 @@ class BusinessUserItemOptimized
|
||||||
$this->treeCalcBot->addProcessedUserId($this->b_user->user_id);
|
$this->treeCalcBot->addProcessedUserId($this->b_user->user_id);
|
||||||
} else {
|
} else {
|
||||||
// Fallback für Rückwärtskompatibilität - sollte in Logs sichtbar sein
|
// 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -458,6 +465,7 @@ class BusinessUserItemOptimized
|
||||||
{
|
{
|
||||||
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}");
|
\Log::warning("BusinessUserItem: Trying to add points to non-existent line {$line}");
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -495,13 +503,14 @@ class BusinessUserItemOptimized
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$calculator = new GrowthBonusCalculator();
|
$calculator = new GrowthBonusCalculator;
|
||||||
// Array zu Object konvertieren für Calculator
|
// Array zu Object konvertieren für Calculator
|
||||||
$qualData = (object) $this->b_user->qual_user_level;
|
$qualData = (object) $this->b_user->qual_user_level;
|
||||||
|
|
||||||
return $calculator->getCalculationDetails($this, $qualData);
|
return $calculator->getCalculationDetails($this, $qualData);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
\Log::error("BusinessUserItem: Error getting growth bonus breakdown: " . $e->getMessage());
|
\Log::error('BusinessUserItem: Error getting growth bonus breakdown: '.$e->getMessage());
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -538,13 +547,14 @@ class BusinessUserItemOptimized
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$calculator = new GrowthBonusCalculator();
|
$calculator = new GrowthBonusCalculator;
|
||||||
// Array zu Object konvertieren für Calculator
|
// Array zu Object konvertieren für Calculator
|
||||||
$qualData = (object) $this->b_user->qual_user_level;
|
$qualData = (object) $this->b_user->qual_user_level;
|
||||||
|
|
||||||
return $calculator->getMatrixDetails($this, $qualData);
|
return $calculator->getMatrixDetails($this, $qualData);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
\Log::error("BusinessUserItem: Error getting growth bonus matrix: " . $e->getMessage());
|
\Log::error('BusinessUserItem: Error getting growth bonus matrix: '.$e->getMessage());
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -556,7 +566,7 @@ class BusinessUserItemOptimized
|
||||||
|
|
||||||
public function isQualKP(): bool
|
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
|
public function isQualLevel(): bool
|
||||||
|
|
@ -630,7 +640,8 @@ class BusinessUserItemOptimized
|
||||||
if (! $this->b_user->qual_user_level) {
|
if (! $this->b_user->qual_user_level) {
|
||||||
return false;
|
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
|
public function getQualPaylines(): int
|
||||||
|
|
@ -638,12 +649,14 @@ class BusinessUserItemOptimized
|
||||||
if (! $this->b_user->qual_user_level) {
|
if (! $this->b_user->qual_user_level) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (int) $this->b_user->qual_user_level['paylines'];
|
return (int) $this->b_user->qual_user_level['paylines'];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getRestQualKP(): float
|
public function getRestQualKP(): float
|
||||||
{
|
{
|
||||||
$ret = $this->b_user->sales_volume_points_KP_sum - $this->b_user->qual_kp;
|
$ret = $this->b_user->sales_volume_points_KP_sum - $this->b_user->qual_kp;
|
||||||
|
|
||||||
return max(0, $ret); // Boundary-Check
|
return max(0, $ret); // Boundary-Check
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -741,10 +754,9 @@ class BusinessUserItemOptimized
|
||||||
} else {
|
} else {
|
||||||
// Neue Logik ab Dezember 2025 - delegated to new Calculator service
|
// Neue Logik ab Dezember 2025 - delegated to new Calculator service
|
||||||
try {
|
try {
|
||||||
$growthCalculator = new GrowthBonusCalculator();
|
$growthCalculator = new GrowthBonusCalculator;
|
||||||
$commission_growth_total = $growthCalculator->calculate($this, $qualUserLevel);
|
$commission_growth_total = $growthCalculator->calculate($this, $qualUserLevel);
|
||||||
|
|
||||||
|
|
||||||
// Calculate matrix details for storage and total sum
|
// Calculate matrix details for storage and total sum
|
||||||
// This ensures that the stored details match the calculated total exactly
|
// This ensures that the stored details match the calculated total exactly
|
||||||
$matrixDetails = $growthCalculator->getMatrixDetails($this, $qualUserLevel);
|
$matrixDetails = $growthCalculator->getMatrixDetails($this, $qualUserLevel);
|
||||||
|
|
@ -824,13 +836,12 @@ class BusinessUserItemOptimized
|
||||||
foreach ($qualUserLevels as $qualUserLevel) {
|
foreach ($qualUserLevels as $qualUserLevel) {
|
||||||
// Berechne die Payline-Punkte für die spezifischen Paylines dieses Levels
|
// Berechne die Payline-Punkte für die spezifischen Paylines dieses Levels
|
||||||
$payline_points = $this->getPointsforPayline($qualUserLevel->paylines);
|
$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
|
// 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!)
|
// 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);
|
$rest_kp = max(0, $this->b_user->sales_volume_points_KP_sum - $qualUserLevel->qual_kp);
|
||||||
$payline_points_qual_kp = $payline_points + $rest_kp;
|
$payline_points_qual_kp = $payline_points + $rest_kp;
|
||||||
|
|
||||||
|
|
||||||
// Prüfe ob die Qualifikation für diesen spezifischen Level erfüllt ist
|
// Prüfe ob die Qualifikation für diesen spezifischen Level erfüllt ist
|
||||||
if ($payline_points_qual_kp >= $qualUserLevel->qual_pp) {
|
if ($payline_points_qual_kp >= $qualUserLevel->qual_pp) {
|
||||||
// Setze die berechneten Werte
|
// 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");
|
\Log::debug("BusinessUserItemOptimized: User {$this->b_user->user_id} does not qualify for any level");
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getPointsforPayline($paylines): float
|
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;
|
$payline_points = 0;
|
||||||
for ($i = 1; $i <= $paylines; $i++) {
|
for ($i = 1; $i <= $paylines; $i++) {
|
||||||
if (isset($this->b_user->business_lines[$i])) {
|
if (isset($this->b_user->business_lines[$i])) {
|
||||||
|
|
@ -867,8 +879,10 @@ class BusinessUserItemOptimized
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $payline_points;
|
return $payline_points;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Setzt das nächste Provision-Level
|
* 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
|
* Wenn das aktuelle Level nicht erreicht ist, dann wird bei aktuelle Provisions-Stufe die erreichte level angezeigt und berechnet
|
||||||
|
|
@ -914,6 +928,7 @@ class BusinessUserItemOptimized
|
||||||
$this->b_user->next_qual_user_level = null;
|
$this->b_user->next_qual_user_level = null;
|
||||||
$this->b_user->next_can_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)");
|
\Log::debug("BusinessUserItemOptimized: No next level found for user {$this->b_user->user_id} (already at highest level)");
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -935,6 +950,7 @@ class BusinessUserItemOptimized
|
||||||
$this->b_user->next_can_user_level = $levelData;
|
$this->b_user->next_can_user_level = $levelData;
|
||||||
$this->b_user->next_qual_user_level = null;
|
$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})");
|
\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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -982,7 +998,7 @@ class BusinessUserItemOptimized
|
||||||
'sales_volume_points_KP_sum' => 'sales_volume_points_KP_sum',
|
'sales_volume_points_KP_sum' => 'sales_volume_points_KP_sum',
|
||||||
'sales_volume_points_TP_sum' => 'sales_volume_points_TP_sum',
|
'sales_volume_points_TP_sum' => 'sales_volume_points_TP_sum',
|
||||||
'business_lines' => 'business_lines',
|
'business_lines' => 'business_lines',
|
||||||
'user_id' => 'user_id'
|
'user_id' => 'user_id',
|
||||||
];
|
];
|
||||||
|
|
||||||
if (isset($legacyMap[$name]) && isset($this->b_user->{$legacyMap[$name]})) {
|
if (isset($legacyMap[$name]) && isset($this->b_user->{$legacyMap[$name]})) {
|
||||||
|
|
@ -1003,7 +1019,7 @@ class BusinessUserItemOptimized
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$sponsor = new stdClass();
|
$sponsor = new stdClass;
|
||||||
$sponsor->is_sponsor = false;
|
$sponsor->is_sponsor = false;
|
||||||
$sponsor->user_id = false;
|
$sponsor->user_id = false;
|
||||||
$sponsor->first_name = '';
|
$sponsor->first_name = '';
|
||||||
|
|
@ -1054,6 +1070,7 @@ class BusinessUserItemOptimized
|
||||||
$maxDepth = 20;
|
$maxDepth = 20;
|
||||||
if ($depth > $maxDepth) {
|
if ($depth > $maxDepth) {
|
||||||
Log::warning("BusinessUserItem: Maximale Rekursionstiefe ({$maxDepth}) erreicht für User {$this->b_user->user_id}");
|
Log::warning("BusinessUserItem: Maximale Rekursionstiefe ({$maxDepth}) erreicht für User {$this->b_user->user_id}");
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1065,6 +1082,7 @@ class BusinessUserItemOptimized
|
||||||
->where('users.id', '!=', 1)
|
->where('users.id', '!=', 1)
|
||||||
->where('users.admin', '<', 4)
|
->where('users.admin', '<', 4)
|
||||||
->where('users.m_level', '!=', null)
|
->where('users.m_level', '!=', null)
|
||||||
|
->whereColumn('users.id', '!=', 'users.m_sponsor')
|
||||||
->where('users.m_sponsor', '=', $this->b_user->user_id)
|
->where('users.m_sponsor', '=', $this->b_user->user_id)
|
||||||
->where('users.payment_account', '!=', null)
|
->where('users.payment_account', '!=', null)
|
||||||
->where('users.active_date', '<=', $this->date->end_date)
|
->where('users.active_date', '<=', $this->date->end_date)
|
||||||
|
|
@ -1075,6 +1093,7 @@ class BusinessUserItemOptimized
|
||||||
// KRITISCHER BUGFIX: Prüfe ob User bereits verarbeitet wurde
|
// KRITISCHER BUGFIX: Prüfe ob User bereits verarbeitet wurde
|
||||||
if ($this->treeCalcBot && $this->treeCalcBot->isUserProcessed($user->id)) {
|
if ($this->treeCalcBot && $this->treeCalcBot->isUserProcessed($user->id)) {
|
||||||
Log::debug("BusinessUserItem: Überspringe bereits verarbeiteten User {$user->id} (zirkuläre Referenz verhindert)");
|
Log::debug("BusinessUserItem: Überspringe bereits verarbeiteten User {$user->id} (zirkuläre Referenz verhindert)");
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1104,6 +1123,7 @@ class BusinessUserItemOptimized
|
||||||
$maxDepth = 50;
|
$maxDepth = 50;
|
||||||
if ($depth > $maxDepth) {
|
if ($depth > $maxDepth) {
|
||||||
Log::warning("BusinessUserItem: Maximale Rekursionstiefe ({$maxDepth}) erreicht für gespeicherte User {$this->b_user->user_id}");
|
Log::warning("BusinessUserItem: Maximale Rekursionstiefe ({$maxDepth}) erreicht für gespeicherte User {$this->b_user->user_id}");
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1115,6 +1135,7 @@ class BusinessUserItemOptimized
|
||||||
// KRITISCHER BUGFIX: Prüfe ob User bereits verarbeitet wurde
|
// KRITISCHER BUGFIX: Prüfe ob User bereits verarbeitet wurde
|
||||||
if ($this->treeCalcBot && $this->treeCalcBot->isUserProcessed($obj->user_id)) {
|
if ($this->treeCalcBot && $this->treeCalcBot->isUserProcessed($obj->user_id)) {
|
||||||
Log::debug("BusinessUserItem: Überspringe bereits verarbeiteten gespeicherten User {$obj->user_id} (zirkuläre Referenz verhindert)");
|
Log::debug("BusinessUserItem: Überspringe bereits verarbeiteten gespeicherten User {$obj->user_id} (zirkuläre Referenz verhindert)");
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1175,6 +1196,7 @@ class BusinessUserItemOptimized
|
||||||
if ($this->b_user && isset($this->b_user->qual_user_level) && $this->b_user->qual_user_level) {
|
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 $this->b_user->qual_user_level['paylines'] ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1189,6 +1211,7 @@ class BusinessUserItemOptimized
|
||||||
return $object->growth_bonus > 0;
|
return $object->growth_bonus > 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1204,6 +1227,7 @@ class BusinessUserItemOptimized
|
||||||
$account = $user->account;
|
$account = $user->account;
|
||||||
if ($account instanceof UserAccount) {
|
if ($account instanceof UserAccount) {
|
||||||
\Log::debug("BusinessUserItem: Using pre-loaded account for user {$user->id}");
|
\Log::debug("BusinessUserItem: Using pre-loaded account for user {$user->id}");
|
||||||
|
|
||||||
return $account;
|
return $account;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1211,6 +1235,7 @@ class BusinessUserItemOptimized
|
||||||
// Wenn User keine account_id hat, gibt es definitiv kein 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");
|
\Log::info("BusinessUserItem: User {$user->id} has no account_id - no account available");
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1220,13 +1245,16 @@ class BusinessUserItemOptimized
|
||||||
|
|
||||||
if (! $account) {
|
if (! $account) {
|
||||||
\Log::warning("BusinessUserItem: Account {$user->account_id} not found for user {$user->id}");
|
\Log::warning("BusinessUserItem: Account {$user->account_id} not found for user {$user->id}");
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
\Log::debug("BusinessUserItem: Successfully loaded account {$account->id} for user {$user->id}");
|
\Log::debug("BusinessUserItem: Successfully loaded account {$account->id} for user {$user->id}");
|
||||||
|
|
||||||
return $account;
|
return $account;
|
||||||
} catch (\Exception $e) {
|
} 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;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ namespace App\Services\BusinessPlan;
|
||||||
|
|
||||||
use App\Models\ShoppingOrder;
|
use App\Models\ShoppingOrder;
|
||||||
use App\Models\UserSalesVolume;
|
use App\Models\UserSalesVolume;
|
||||||
|
use App\Services\Incentive\IncentiveTracker;
|
||||||
use App\Services\Util;
|
use App\Services\Util;
|
||||||
use App\User;
|
use App\User;
|
||||||
use stdClass;
|
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
|
// Neuberechnung für aktuellen Monat
|
||||||
self::reCalculateSalesPointsVolume($original_sales_volume->user_id, $month, $year);
|
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', [
|
\Log::info('Punktekorrektur für Stornorechnung durchgeführt', [
|
||||||
'original_invoice_id' => $original_sales_volume->user_invoice_id,
|
'original_invoice_id' => $original_sales_volume->user_invoice_id,
|
||||||
'cancellation_invoice_id' => $cancellation_invoice_id,
|
'cancellation_invoice_id' => $cancellation_invoice_id,
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,16 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Models\Tag;
|
|
||||||
use App\Models\DcTag;
|
|
||||||
use App\Models\FileTag;
|
|
||||||
use App\Models\DcFileTag;
|
use App\Models\DcFileTag;
|
||||||
|
use App\Models\DcTag;
|
||||||
|
|
||||||
class DcHelper {
|
class DcHelper
|
||||||
|
{
|
||||||
|
|
||||||
public static $points;
|
public static $points;
|
||||||
|
|
||||||
public static function getTransChange(){
|
public static function getTransChange()
|
||||||
|
{
|
||||||
|
|
||||||
$langs = [
|
$langs = [
|
||||||
'de' => ['name' => 'German', 'script' => 'Latn', 'native' => 'Deutsch', 'regional' => 'de_DE'],
|
'de' => ['name' => 'German', 'script' => 'Latn', 'native' => 'Deutsch', 'regional' => 'de_DE'],
|
||||||
|
|
@ -23,57 +22,64 @@ class DcHelper {
|
||||||
foreach ($langs as $code => $lang) {
|
foreach ($langs as $code => $lang) {
|
||||||
$ret[strtolower($code)] = strtolower($lang['native']);
|
$ret[strtolower($code)] = strtolower($lang['native']);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $ret;
|
return $ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function makeNestableList($category_id){
|
public static function makeNestableList($category_id)
|
||||||
|
{
|
||||||
$tags = DcTag::where('category_id', $category_id)->orderBy('pos')->get();
|
$tags = DcTag::where('category_id', $category_id)->orderBy('pos')->get();
|
||||||
$out = "";
|
$out = '';
|
||||||
foreach ($tags as $tag) {
|
foreach ($tags as $tag) {
|
||||||
|
|
||||||
$out .= '<li class="dd-item" data-id="'.$tag->id.'">
|
$out .= '<li class="dd-item" data-id="'.$tag->id.'">
|
||||||
<span class="pull-right">
|
<div style="display: inline-block;">
|
||||||
<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']).'">
|
<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>').'
|
'.($tag->active ? '<i class="fa fa-eye text-success"></i>' : '<i class="fa fa-eye-slash text-danger"></i>').'
|
||||||
</a>
|
</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>
|
<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>
|
||||||
</span>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="dd-handle">
|
<div class="dd-handle">
|
||||||
'.$tag->name.'
|
'.$tag->name.'
|
||||||
</div>
|
</div>
|
||||||
</li>';
|
</li>';
|
||||||
}
|
}
|
||||||
|
|
||||||
return $out;
|
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();
|
$tags = DcTag::where('category_id', $category_id)->orderBy('pos')->get();
|
||||||
$file_tags = DcFileTag::where('file_id', $file_id)->get();
|
$file_tags = DcFileTag::where('file_id', $file_id)->get();
|
||||||
|
|
||||||
$search = array();
|
$search = [];
|
||||||
foreach ($file_tags as $file_tag) {
|
foreach ($file_tags as $file_tag) {
|
||||||
$search[] = $file_tag->tag_id;
|
$search[] = $file_tag->tag_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
$out = "";
|
$out = '';
|
||||||
foreach ($tags as $tag) {
|
foreach ($tags as $tag) {
|
||||||
$out .= '<li class="dd-item" data-id="'.$tag->id.'">
|
$out .= '<li class="dd-item" data-id="'.$tag->id.'">
|
||||||
<div class="dd-handle dd-nodrag">
|
<div class="dd-handle dd-nodrag">
|
||||||
<label class="custom-control custom-checkbox m-0" for="nestable_check_'.$tag->id.'">
|
<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>
|
<span class="custom-control-label"> '.$tag->name.' </span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</li>';
|
</li>';
|
||||||
}
|
}
|
||||||
|
|
||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function makeFilterList($filter_list, $split = false, $chunk = false){
|
public static function makeFilterList($filter_list, $split = false, $chunk = false)
|
||||||
|
{
|
||||||
|
|
||||||
$out = "";
|
$out = '';
|
||||||
$splitOn = 0;
|
$splitOn = 0;
|
||||||
if ($split) {
|
if ($split) {
|
||||||
$count = count($filter_list);
|
$count = count($filter_list);
|
||||||
|
|
@ -82,7 +88,6 @@ class DcHelper {
|
||||||
$filter_chunk = array_chunk($filter_list, $splitOn, true);
|
$filter_chunk = array_chunk($filter_list, $splitOn, true);
|
||||||
$filter_list = $filter_chunk[$chunk];
|
$filter_list = $filter_chunk[$chunk];
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($filter_list as $category_id => $value) {
|
foreach ($filter_list as $category_id => $value) {
|
||||||
|
|
@ -90,19 +95,17 @@ class DcHelper {
|
||||||
$out .= '<select class="selectpicker category-filter" name="categories['.$category_id.'][]" id="category_'.$category_id.'" data-style="btn-light" data-live-search="true" multiple>';
|
$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 .= '<option value="'.$tag->id.'">'.$tag->name.' ('.$tag->count.')</option>';
|
||||||
|
|
||||||
}
|
}
|
||||||
$out .= '</select>';
|
$out .= '</select>';
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getAttributesOptions($ids = array(), $all = true){
|
private function getAttributesOptions($ids = [], $all = true)
|
||||||
$ret = "";
|
{
|
||||||
|
$ret = '';
|
||||||
|
|
||||||
return $ret;
|
return $ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -117,7 +117,9 @@ class HTMLHelper
|
||||||
foreach ($values as $value) {
|
foreach ($values as $value) {
|
||||||
$attr = ($value == $default) ? 'selected="selected"' : '';
|
$attr = ($value == $default) ? 'selected="selected"' : '';
|
||||||
$str = self::getAboStrLang($value);
|
$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;
|
return $ret;
|
||||||
|
|
|
||||||
54
app/Services/Incentive/IncentiveCalculationService.php
Normal file
54
app/Services/Incentive/IncentiveCalculationService.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
353
app/Services/Incentive/IncentivePointsLogRepairService.php
Normal file
353
app/Services/Incentive/IncentivePointsLogRepairService.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
373
app/Services/Incentive/IncentiveTracker.php
Normal file
373
app/Services/Incentive/IncentiveTracker.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Services/LocaleGuard.php
Normal file
30
app/Services/LocaleGuard.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ use App\Models\UserCreditItem;
|
||||||
use App\Models\UserLevel;
|
use App\Models\UserLevel;
|
||||||
use App\Repositories\InvoiceRepository;
|
use App\Repositories\InvoiceRepository;
|
||||||
use App\Services\BusinessPlan\SalesPointsVolume;
|
use App\Services\BusinessPlan\SalesPointsVolume;
|
||||||
|
use App\Services\Incentive\IncentiveTracker;
|
||||||
use App\User;
|
use App\User;
|
||||||
use Illuminate\Support\Facades\Mail;
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
|
||||||
|
|
@ -277,17 +278,47 @@ class Payment
|
||||||
|
|
||||||
// the Order is Pay, so we can set the Status in the Abo
|
// the Order is Pay, so we can set the Status in the Abo
|
||||||
if ($shopping_order->is_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);
|
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
|
// 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)) {
|
if ($shopping_order->mode === 'live' || Util::isTestSystem(true)) {
|
||||||
// Reload the shopping order to check for invoice again (defense against race conditions)
|
// Reload the shopping order to check for invoice again (defense against race conditions)
|
||||||
$shopping_order->refresh();
|
$shopping_order->refresh();
|
||||||
|
|
||||||
if (! $shopping_order->isInvoice()) {
|
if (! $shopping_order->isInvoice()) {
|
||||||
|
try {
|
||||||
$invoice_repo = new InvoiceRepository($shopping_order);
|
$invoice_repo = new InvoiceRepository($shopping_order);
|
||||||
$invoice_repo->createAndSalesVolume();
|
$invoice_repo->createAndSalesVolume();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
\Log::error('Payment::paymentStatusPaidAction - Rechnungserstellung fehlgeschlagen', [
|
||||||
|
'shopping_order_id' => $shopping_order->id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This class is a wrapper to be able to send arrays of Payone request
|
* This class is a wrapper to be able to send arrays of Payone request
|
||||||
* to the Payone platform.
|
* to the Payone platform.
|
||||||
|
|
@ -16,8 +17,8 @@
|
||||||
* You should have received a copy of the GNU General Public License
|
* You should have received a copy of the GNU General Public License
|
||||||
* along with Payone Connector. If not, see <http://www.gnu.org/licenses/>.
|
* along with Payone Connector. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*
|
*
|
||||||
* @package Simple PHP Integration
|
|
||||||
* @link https://www.bspayone.com/
|
* @link https://www.bspayone.com/
|
||||||
|
*
|
||||||
* @copyright (C) BS PAYONE GmbH 2016, 2018
|
* @copyright (C) BS PAYONE GmbH 2016, 2018
|
||||||
* @author Florian Bender <florian.bender@bspayone.com>
|
* @author Florian Bender <florian.bender@bspayone.com>
|
||||||
* @author Timo Kuchel <timo.kuchel@bspayone.com>
|
* @author Timo Kuchel <timo.kuchel@bspayone.com>
|
||||||
|
|
@ -26,22 +27,24 @@
|
||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
|
|
||||||
// require 'vendor/autoload.php';
|
// require 'vendor/autoload.php';
|
||||||
use Exception;
|
use Exception;
|
||||||
|
|
||||||
use GuzzleHttp\Client;
|
use GuzzleHttp\Client;
|
||||||
|
use GuzzleHttp\Exception\BadResponseException;
|
||||||
|
use GuzzleHttp\Exception\ConnectException;
|
||||||
|
use GuzzleHttp\Exception\RequestException;
|
||||||
use Psr\Http\Message\ResponseInterface;
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class Payone
|
* Class Payone
|
||||||
*/
|
*/
|
||||||
class Payone {
|
class Payone
|
||||||
|
{
|
||||||
/**
|
/**
|
||||||
* The URL of the Payone API
|
* The URL of the Payone API
|
||||||
*/
|
*/
|
||||||
const PAYONE_SERVER_API_URL = 'https://api.pay1.de/post-gateway/';
|
const PAYONE_SERVER_API_URL = 'https://api.pay1.de/post-gateway/';
|
||||||
|
|
||||||
const PAYONE_CLIENT_API_URL = 'https://secure.pay1.de/client-api/';
|
const PAYONE_CLIENT_API_URL = 'https://secure.pay1.de/client-api/';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -49,41 +52,77 @@ class Payone {
|
||||||
*
|
*
|
||||||
* @param array $request
|
* @param array $request
|
||||||
* @param string $responsetype
|
* @param string $responsetype
|
||||||
* @throws Exception
|
* @param Client|null $client Optional Guzzle client (e.g. mocked in tests).
|
||||||
* @return array|\Psr\Http\Message\StreamInterface Returns an array of response
|
* @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") {
|
if ($client === null) {
|
||||||
|
if ($responsetype === 'json') {
|
||||||
// appends the accept: application/json header to the request
|
// appends the accept: application/json header to the request
|
||||||
// This is used to retrieve structured JSON in the response
|
// 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', 'content-type' => 'text/plain;charset=UTF-8']]);
|
||||||
$client = new Client(['headers' => ['accept' => 'application/json']]);
|
$client = new Client(['headers' => ['accept' => 'application/json']]);
|
||||||
|
} else {
|
||||||
}
|
|
||||||
else {
|
|
||||||
// if $responsetype is set to anything else than "json", use the standard request
|
// 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(['headers' => ['content-type' => 'text/plain;charset=UTF-8']]);
|
||||||
$client = new Client();
|
$client = new Client;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// echo "Requesting...";
|
// echo "Requesting...";
|
||||||
$begin = microtime(true);
|
$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 {
|
try {
|
||||||
$response = $client->request('POST', self::PAYONE_SERVER_API_URL, ['form_params' => $request]);
|
$response = $client->request('POST', self::PAYONE_SERVER_API_URL, ['form_params' => $request]);
|
||||||
}
|
} catch (BadResponseException $e) {
|
||||||
catch (\GuzzleHttp\Exception\ClientException $e) {
|
|
||||||
$error = $e->getResponse();
|
$error = $e->getResponse();
|
||||||
$responseBodyAsString = $error->getBody()->getContents();
|
$responseBodyAsString = $error->getBody()->getContents();
|
||||||
MyLog::writeLog(
|
MyLog::writeLog(
|
||||||
'payone',
|
'payone',
|
||||||
'error',
|
'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.',
|
'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]
|
['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 (isset($response)) {
|
||||||
if (implode($response->getHeader('Content-Type')) == 'text/plain;charset=UTF-8') {
|
if (implode($response->getHeader('Content-Type')) == 'text/plain;charset=UTF-8') {
|
||||||
|
|
@ -101,7 +140,6 @@ class Payone {
|
||||||
['error' => $return, 'response' => $response, 'request' => $request]
|
['error' => $return, 'response' => $response, 'request' => $request]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
MyLog::writeLog(
|
MyLog::writeLog(
|
||||||
'payone',
|
'payone',
|
||||||
|
|
@ -133,6 +171,7 @@ class Payone {
|
||||||
);
|
);
|
||||||
abort(403, 'Der Zahlungsanbieter ist nicht erreichbar, die Zahlung konnte nicht durchgeführt werden. Bitte versuchen Sie es später erneut. Fehlercode: 1004');
|
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 "done.\n";
|
||||||
echo "Request took " . $duration . " seconds.\n";
|
echo "Request took " . $duration . " seconds.\n";
|
||||||
echo "<br>";
|
echo "<br>";
|
||||||
|
|
@ -143,27 +182,28 @@ class Payone {
|
||||||
/**
|
/**
|
||||||
* gets response string an puts it into an array
|
* gets response string an puts it into an array
|
||||||
*
|
*
|
||||||
* @param \Psr\Http\Message\ResponseInterface $response
|
|
||||||
* @throws Exception
|
|
||||||
* @return array
|
* @return array
|
||||||
|
*
|
||||||
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
public static function parseResponse(ResponseInterface $response)
|
public static function parseResponse(ResponseInterface $response)
|
||||||
{
|
{
|
||||||
$responseArray = array();
|
$responseArray = [];
|
||||||
$explode = explode("\n", $response->getBody());
|
$explode = explode("\n", $response->getBody());
|
||||||
foreach ($explode as $e) {
|
foreach ($explode as $e) {
|
||||||
$keyValue = explode("=", $e);
|
$keyValue = explode('=', $e);
|
||||||
if (trim($keyValue[0]) != "") {
|
if (trim($keyValue[0]) != '') {
|
||||||
if (count($keyValue) == 2) {
|
if (count($keyValue) == 2) {
|
||||||
$responseArray[$keyValue[0]] = trim($keyValue[1]);
|
$responseArray[$keyValue[0]] = trim($keyValue[1]);
|
||||||
} else {
|
} else {
|
||||||
$key = $keyValue[0];
|
$key = $keyValue[0];
|
||||||
unset($keyValue[0]);
|
unset($keyValue[0]);
|
||||||
$value = implode("=", $keyValue);
|
$value = implode('=', $keyValue);
|
||||||
$responseArray[$key] = $value;
|
$responseArray[$key] = $value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*if ($responseArray['status'] == "ERROR") {
|
/*if ($responseArray['status'] == "ERROR") {
|
||||||
$msg = "Payone returned an error:\n" . print_r($responseArray, true);
|
$msg = "Payone returned an error:\n" . print_r($responseArray, true);
|
||||||
throw new Exception($msg);
|
throw new Exception($msg);
|
||||||
|
|
|
||||||
51
app/Services/ProductOrderContext.php
Normal file
51
app/Services/ProductOrderContext.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
127
app/Services/SyS/AboOrdersOverview.php
Normal file
127
app/Services/SyS/AboOrdersOverview.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
536
app/Services/SyS/PayoneCallbackTestbench.php
Normal file
536
app/Services/SyS/PayoneCallbackTestbench.php
Normal 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 2–3) abschließen oder ID eintragen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$userAbo = UserAbo::query()
|
||||||
|
->with(['user_abo_items', 'shopping_user'])
|
||||||
|
->findOrFail($userAboId);
|
||||||
|
|
||||||
|
AboHelper::ensureUserAboItemsFromLatestOrder($userAbo);
|
||||||
|
$userAbo->refresh();
|
||||||
|
$userAbo->load(['user_abo_items', 'shopping_user']);
|
||||||
|
|
||||||
|
if (! $userAbo->active || (int) $userAbo->status !== 2) {
|
||||||
|
return Redirect::route('sysadmin_tool', ['payone_callback_testbench'])
|
||||||
|
->with('error', 'UserAbo nicht aktiv oder nicht status 2 (abo_okay).');
|
||||||
|
}
|
||||||
|
if ($userAbo->user_abo_items->isEmpty()) {
|
||||||
|
return Redirect::route('sysadmin_tool', ['payone_callback_testbench'])
|
||||||
|
->with('error', 'Keine Abo-Artikel – Verlängerung nicht möglich.');
|
||||||
|
}
|
||||||
|
|
||||||
|
self::prepareUserAboForCronRun($userAbo);
|
||||||
|
$userAbo->refresh();
|
||||||
|
|
||||||
|
$command = new UserMakeAboOrder;
|
||||||
|
self::bindNullConsoleOutput($command);
|
||||||
|
|
||||||
|
$makeOrder = new ReflectionMethod(UserMakeAboOrder::class, 'makeOrder');
|
||||||
|
$makeOrder->setAccessible(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
/** @var ShoppingOrder|null $shoppingOrder */
|
||||||
|
$shoppingOrder = $makeOrder->invoke($command, $userAbo->fresh(['user_abo_items', 'shopping_user']));
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return Redirect::route('sysadmin_tool', ['payone_callback_testbench'])
|
||||||
|
->with('error', 'Cron makeOrder: '.$e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $shoppingOrder) {
|
||||||
|
session()->put('payone_testbench_cron_renewal', [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'makeOrder hat keine Bestellung zurückgegeben (typisch: makeShoppingOrder false, z. B. fehlende Referenz-Bestellung/user_shop_id, oder createShoppingUser liefert nichts).',
|
||||||
|
'diagnosis' => self::cronRenewalDiagnosis($userAbo->fresh(['user_abo_items', 'shopping_user'])),
|
||||||
|
]);
|
||||||
|
session()->forget('payone_testbench_cron_renewal_order_id');
|
||||||
|
|
||||||
|
return Redirect::route('sysadmin_tool', ['payone_callback_testbench']);
|
||||||
|
}
|
||||||
|
|
||||||
|
session()->put('payone_testbench_cron_renewal', [
|
||||||
|
'success' => true,
|
||||||
|
'shopping_order_id' => $shoppingOrder->id,
|
||||||
|
'user_abo_id' => $userAbo->id,
|
||||||
|
'hint' => 'Entspricht user:make_abo_order / makeOrder. Für Rechnung und Incentive-Umsatz wie in Produktion: Schritt 5 (Payone paid) für diese Verlängerungs-Bestellung ausführen.',
|
||||||
|
]);
|
||||||
|
session()->put('payone_testbench_cron_renewal_order_id', $shoppingOrder->id);
|
||||||
|
|
||||||
|
return Redirect::route('sysadmin_tool', ['payone_callback_testbench']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ohne Log-Datei: Ursachen fuer fehlgeschlagenes makeOrder eingrenzen.
|
||||||
|
*
|
||||||
|
* @return array<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,36 +1,47 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Models\ShippingCountry;
|
use App\Models\ShippingCountry;
|
||||||
use App\User;
|
use App\User;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use RuntimeException;
|
||||||
use Yard;
|
use Yard;
|
||||||
|
|
||||||
class UserService
|
class UserService
|
||||||
{
|
{
|
||||||
public static $user_country;
|
public static $user_country;
|
||||||
|
|
||||||
public static $shipping_country;
|
public static $shipping_country;
|
||||||
|
|
||||||
public static $shipping_free = false;
|
public static $shipping_free = false;
|
||||||
|
|
||||||
public static $user_tax_free = false;
|
public static $user_tax_free = false;
|
||||||
|
|
||||||
public static $user_reverse_charge = false;
|
public static $user_reverse_charge = false;
|
||||||
|
|
||||||
public static $instance = 'shopping';
|
public static $instance = 'shopping';
|
||||||
|
|
||||||
public static function getTransChange(){
|
public static function getTransChange()
|
||||||
|
{
|
||||||
|
|
||||||
$langs = config('localization.supportedLocales');
|
$langs = config('localization.supportedLocales');
|
||||||
$ret = [];
|
$ret = [];
|
||||||
foreach ($langs as $code => $lang) {
|
foreach ($langs as $code => $lang) {
|
||||||
$ret[strtolower($code)] = strtolower($lang['native']);
|
$ret[strtolower($code)] = strtolower($lang['native']);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $ret;
|
return $ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function setInstance($instance){
|
public static function setInstance($instance)
|
||||||
|
{
|
||||||
self::$instance = $instance;
|
self::$instance = $instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
// init Yard for user order Customer
|
// init Yard for user order Customer
|
||||||
public static function initCustomerYard($shopping_user, $for){
|
public static function initCustomerYard($shopping_user, $for)
|
||||||
|
{
|
||||||
self::$user_tax_free = false;
|
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::$user_country = $shopping_user->billing_country;
|
||||||
|
|
@ -42,29 +53,45 @@ class UserService
|
||||||
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;
|
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;
|
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());
|
Yard::instance(self::$instance)->setUserPriceInfos(self::getYardInfo());
|
||||||
}
|
}
|
||||||
|
|
||||||
// init Yard for user order Berater
|
// init Yard for user order Berater
|
||||||
public static function initUserYard(User $user, $shipping_country_id, $for){
|
public static function initUserYard(User $user, $shipping_country_id, $for)
|
||||||
|
{
|
||||||
self::$shipping_free = false;
|
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)->setShippingCountryWithPrice($shipping_country_id, $for);
|
||||||
Yard::instance(self::$instance)->setUserPriceInfos(self::getYardInfo());
|
Yard::instance(self::$instance)->setUserPriceInfos(self::getYardInfo());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function checkUserTaxShippingCountry(User $user, $shipping_country_id)
|
||||||
public static function checkUserTaxShippingCountry(User $user, $shipping_country_id) {
|
{
|
||||||
|
|
||||||
if (! $user->account || ! $user->account->country_id) {
|
if (! $user->account || ! $user->account->country_id) {
|
||||||
abort(403, 'Error: User hat kein Land!');
|
abort(403, 'Error: User hat kein Land!');
|
||||||
}
|
}
|
||||||
$ShippingCountry = ShippingCountry::findOrFail($shipping_country_id);
|
$ShippingCountry = ShippingCountry::findOrFail($shipping_country_id);
|
||||||
self::$user_tax_free = self::performUserTaxShippingCountry($user, $ShippingCountry);
|
self::$user_tax_free = self::performUserTaxShippingCountry($user, $ShippingCountry);
|
||||||
|
|
||||||
return $ShippingCountry;
|
return $ShippingCountry;
|
||||||
/*
|
/*
|
||||||
dump( self::$user_price_code );
|
dump( self::$user_price_code );
|
||||||
|
|
@ -73,7 +100,8 @@ class UserService
|
||||||
*/
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function performUserTaxShippingCountry($user, $ShippingCountry){
|
public static function performUserTaxShippingCountry($user, $ShippingCountry)
|
||||||
|
{
|
||||||
// preise für das Land
|
// preise für das Land
|
||||||
self::$user_country = $user->account->country;
|
self::$user_country = $user->account->country;
|
||||||
self::$shipping_country = $ShippingCountry->country;
|
self::$shipping_country = $ShippingCountry->country;
|
||||||
|
|
@ -94,15 +122,18 @@ class UserService
|
||||||
// Rechnungsland ist auch Lieferland, dann RSV
|
// Rechnungsland ist auch Lieferland, dann RSV
|
||||||
if (strtolower($user->account->reverse_charge_code) == strtolower($ShippingCountry->country->code)) {
|
if (strtolower($user->account->reverse_charge_code) == strtolower($ShippingCountry->country->code)) {
|
||||||
self::$user_reverse_charge = true;
|
self::$user_reverse_charge = true;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lieferland ohne RSV
|
// Lieferland ohne RSV
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getYardInfo(){
|
public static function getYardInfo()
|
||||||
|
{
|
||||||
return [
|
return [
|
||||||
'shipping_free' => self::$shipping_free,
|
'shipping_free' => self::$shipping_free,
|
||||||
'user_tax_free' => self::$user_tax_free,
|
'user_tax_free' => self::$user_tax_free,
|
||||||
|
|
@ -112,11 +143,13 @@ class UserService
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getTaxFree(){
|
public static function getTaxFree()
|
||||||
|
{
|
||||||
return self::$user_tax_free ? true : false;
|
return self::$user_tax_free ? true : false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getUserPriceInfos(){
|
public static function getUserPriceInfos()
|
||||||
|
{
|
||||||
return [
|
return [
|
||||||
'user_tax_free' => self::$user_tax_free,
|
'user_tax_free' => self::$user_tax_free,
|
||||||
'user_reverse_charge' => self::$user_reverse_charge,
|
'user_reverse_charge' => self::$user_reverse_charge,
|
||||||
|
|
@ -124,7 +157,8 @@ class UserService
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getOrderInfo($key = false){
|
public static function getOrderInfo($key = false)
|
||||||
|
{
|
||||||
if (! self::$user_country) {
|
if (! self::$user_country) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
@ -141,20 +175,19 @@ class UserService
|
||||||
case 'user_reverse_charge':
|
case 'user_reverse_charge':
|
||||||
return self::$user_reverse_charge ? __('yes') : __('no');
|
return self::$user_reverse_charge ? __('yes') : __('no');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function createConfirmationCode() {
|
public static function createConfirmationCode()
|
||||||
|
{
|
||||||
$unique = false;
|
$unique = false;
|
||||||
do {
|
do {
|
||||||
$confirmation_code = Str::random(30);
|
$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;
|
$unique = true;
|
||||||
}
|
}
|
||||||
}
|
} while (! $unique);
|
||||||
while(!$unique);
|
|
||||||
return $confirmation_code;
|
return $confirmation_code;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -2,7 +2,9 @@
|
||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\ShoppingOrder;
|
||||||
use App\Models\UserHistory;
|
use App\Models\UserHistory;
|
||||||
|
use App\User;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Request;
|
use Request;
|
||||||
use Yard;
|
use Yard;
|
||||||
|
|
@ -343,6 +345,108 @@ class Util
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
public static function getMyMivitaPortalUrl($protocol = true)
|
||||||
{
|
{
|
||||||
$pro = $protocol ? config('app.protocol') : '';
|
$pro = $protocol ? config('app.protocol') : '';
|
||||||
|
|
|
||||||
120
app/User.php
120
app/User.php
|
|
@ -2,14 +2,14 @@
|
||||||
|
|
||||||
namespace App;
|
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\Mail\MailResetPassword;
|
||||||
|
use App\Models\PaymentMethod;
|
||||||
use App\Models\UserSalesVolume;
|
use App\Models\UserSalesVolume;
|
||||||
|
use Carbon\Carbon;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
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;
|
use Laravel\Passport\HasApiTokens;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -24,6 +24,7 @@ use Laravel\Passport\HasApiTokens;
|
||||||
* @property \Illuminate\Support\Carbon|null $created_at
|
* @property \Illuminate\Support\Carbon|null $created_at
|
||||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||||
* @property-read \Illuminate\Notifications\DatabaseNotificationCollection|\Illuminate\Notifications\DatabaseNotification[] $notifications
|
* @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 whereCreatedAt($value)
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\User whereEmail($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|\App\User whereEmail($value)
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\User whereId($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 whereRememberToken($value)
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\User whereToken($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|\App\User whereToken($value)
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\User whereUpdatedAt($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|\App\User whereUpdatedAt($value)
|
||||||
|
*
|
||||||
* @property int $confirmed
|
* @property int $confirmed
|
||||||
* @property string|null $confirmation_code
|
* @property string|null $confirmation_code
|
||||||
* @property string|null $confirmation_date
|
* @property string|null $confirmation_date
|
||||||
|
|
@ -47,6 +49,7 @@ use Laravel\Passport\HasApiTokens;
|
||||||
* @property \Illuminate\Support\Carbon|null $deleted_at
|
* @property \Illuminate\Support\Carbon|null $deleted_at
|
||||||
* @property-read \App\Models\Account $account
|
* @property-read \App\Models\Account $account
|
||||||
* @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\UserUpdateEmail[] $user_update_email
|
* @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\UserUpdateEmail[] $user_update_email
|
||||||
|
*
|
||||||
* @method static bool|null forceDelete()
|
* @method static bool|null forceDelete()
|
||||||
* @method static \Illuminate\Database\Query\Builder|\App\User onlyTrashed()
|
* @method static \Illuminate\Database\Query\Builder|\App\User onlyTrashed()
|
||||||
* @method static bool|null restore()
|
* @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\Eloquent\Builder|\App\User whereNotes($value)
|
||||||
* @method static \Illuminate\Database\Query\Builder|\App\User withTrashed()
|
* @method static \Illuminate\Database\Query\Builder|\App\User withTrashed()
|
||||||
* @method static \Illuminate\Database\Query\Builder|\App\User withoutTrashed()
|
* @method static \Illuminate\Database\Query\Builder|\App\User withoutTrashed()
|
||||||
|
*
|
||||||
* @property int|null $account_id
|
* @property int|null $account_id
|
||||||
|
*
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\User newModelQuery()
|
* @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 newQuery()
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\User query()
|
* @method static \Illuminate\Database\Eloquent\Builder|\App\User query()
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\User whereAccountId($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|\App\User whereAccountId($value)
|
||||||
|
*
|
||||||
* @property int|null $wizard
|
* @property int|null $wizard
|
||||||
* @property int|null $blocked
|
* @property int|null $blocked
|
||||||
* @property string|null $payment_account
|
* @property string|null $payment_account
|
||||||
|
|
@ -77,16 +83,20 @@ use Laravel\Passport\HasApiTokens;
|
||||||
* @property-read int|null $notifications_count
|
* @property-read int|null $notifications_count
|
||||||
* @property-read \App\Models\UserShop $shop
|
* @property-read \App\Models\UserShop $shop
|
||||||
* @property-read int|null $user_update_email_count
|
* @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 whereBlocked($value)
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\User wherePaymentAccount($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 wherePaymentShop($value)
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\User whereWizard($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|\App\User whereWizard($value)
|
||||||
|
*
|
||||||
* @property int|null $m_level
|
* @property int|null $m_level
|
||||||
* @property int|null $m_sponsor
|
* @property int|null $m_sponsor
|
||||||
* @property-read \App\Models\UserLevel|null $user_level
|
* @property-read \App\Models\UserLevel|null $user_level
|
||||||
* @property-read \App\User|null $user_sponsor
|
* @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 whereMLevel($value)
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\User whereMSponsor($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|\App\User whereMSponsor($value)
|
||||||
|
*
|
||||||
* @property string|null $release_account
|
* @property string|null $release_account
|
||||||
* @property int|null $payment_order_id
|
* @property int|null $payment_order_id
|
||||||
* @property int|null $abo_options
|
* @property int|null $abo_options
|
||||||
|
|
@ -95,9 +105,11 @@ use Laravel\Passport\HasApiTokens;
|
||||||
* @property-read \App\Models\Product|null $payment_order
|
* @property-read \App\Models\Product|null $payment_order
|
||||||
* @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\ShoppingOrder[] $shopping_orders
|
* @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\ShoppingOrder[] $shopping_orders
|
||||||
* @property-read int|null $shopping_orders_count
|
* @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 whereAboOptions($value)
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\User wherePaymentOrderId($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|\App\User wherePaymentOrderId($value)
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\User whereReleaseAccount($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|\App\User whereReleaseAccount($value)
|
||||||
|
*
|
||||||
* @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\UserHistory[] $user_histories
|
* @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\UserHistory[] $user_histories
|
||||||
* @property-read int|null $user_histories_count
|
* @property-read int|null $user_histories_count
|
||||||
* @property int|null $test_mode
|
* @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 \Illuminate\Database\Eloquent\Collection|\App\Models\ShoppingUser[] $member_shopping_users
|
||||||
* @property-read int|null $member_shopping_users_count
|
* @property-read int|null $member_shopping_users_count
|
||||||
* @property-read \App\Models\Product|null $payment_order_product
|
* @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 whereSettings($value)
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\User whereTestMode($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|\App\User whereTestMode($value)
|
||||||
|
*
|
||||||
* @property-read \Illuminate\Database\Eloquent\Collection|\Laravel\Passport\Client[] $clients
|
* @property-read \Illuminate\Database\Eloquent\Collection|\Laravel\Passport\Client[] $clients
|
||||||
* @property-read int|null $clients_count
|
* @property-read int|null $clients_count
|
||||||
* @property-read \Illuminate\Database\Eloquent\Collection|\Laravel\Passport\Token[] $tokens
|
* @property-read \Illuminate\Database\Eloquent\Collection|\Laravel\Passport\Token[] $tokens
|
||||||
* @property-read int|null $tokens_count
|
* @property-read int|null $tokens_count
|
||||||
* @property array|null $payment_methods
|
* @property array|null $payment_methods
|
||||||
|
*
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\User wherePaymentMethods($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|\App\User wherePaymentMethods($value)
|
||||||
|
*
|
||||||
* @property int|null $pre_sponsor
|
* @property int|null $pre_sponsor
|
||||||
* @property-read User|null $user_pre_sponsor
|
* @property-read User|null $user_pre_sponsor
|
||||||
|
*
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|User wherePreSponsor($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|User wherePreSponsor($value)
|
||||||
|
*
|
||||||
* @property \Illuminate\Support\Carbon|null $pre_deleted_at
|
* @property \Illuminate\Support\Carbon|null $pre_deleted_at
|
||||||
|
*
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder<static>|User wherePreDeletedAt($value)
|
* @method static \Illuminate\Database\Eloquent\Builder<static>|User wherePreDeletedAt($value)
|
||||||
|
*
|
||||||
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\UserBusiness> $userBusiness
|
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\UserBusiness> $userBusiness
|
||||||
* @property-read int|null $user_business_count
|
* @property-read int|null $user_business_count
|
||||||
|
*
|
||||||
* @mixin \Eloquent
|
* @mixin \Eloquent
|
||||||
*/
|
*/
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable
|
||||||
{
|
{
|
||||||
use Notifiable, HasApiTokens;
|
use HasApiTokens, Notifiable;
|
||||||
|
|
||||||
use SoftDeletes;
|
use SoftDeletes;
|
||||||
|
|
||||||
protected $dates = ['deleted_at'];
|
protected $dates = ['deleted_at'];
|
||||||
|
|
||||||
protected $table = 'users';
|
protected $table = 'users';
|
||||||
|
|
@ -138,7 +159,6 @@ class User extends Authenticatable
|
||||||
*
|
*
|
||||||
* @var array
|
* @var array
|
||||||
*/
|
*/
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'email',
|
'email',
|
||||||
'password',
|
'password',
|
||||||
|
|
@ -195,7 +215,6 @@ class User extends Authenticatable
|
||||||
return $this->hasMany('App\Models\File', 'user_id', '');
|
return $this->hasMany('App\Models\File', 'user_id', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public function shopping_orders()
|
public function shopping_orders()
|
||||||
{
|
{
|
||||||
return $this->hasMany('App\Models\ShoppingOrder', 'auth_user_id', '');
|
return $this->hasMany('App\Models\ShoppingOrder', 'auth_user_id', '');
|
||||||
|
|
@ -236,24 +255,26 @@ class User extends Authenticatable
|
||||||
return $this->lang ? $this->lang : \App::getLocale();
|
return $this->lang ? $this->lang : \App::getLocale();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public function getMUserSponsor()
|
public function getMUserSponsor()
|
||||||
{
|
{
|
||||||
if ($this->user_sponsor && $this->user_sponsor->account) {
|
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)
|
public function getFullName($email = true)
|
||||||
{
|
{
|
||||||
$ret = "";
|
$ret = '';
|
||||||
if ($this->account) {
|
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) {
|
if ($email && $this->id > 1) {
|
||||||
$ret .= " | " . $this->email;
|
$ret .= ' | '.$this->email;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $ret;
|
return $ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
|
|
@ -262,10 +283,10 @@ class User extends Authenticatable
|
||||||
if ($this->password == env('APP_KEY')) {
|
if ($this->password == env('APP_KEY')) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
|
|
@ -274,10 +295,10 @@ class User extends Authenticatable
|
||||||
if ($this->admin >= 1) {
|
if ($this->admin >= 1) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
|
|
@ -286,6 +307,7 @@ class User extends Authenticatable
|
||||||
if ($this->admin >= 2) {
|
if ($this->admin >= 2) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -297,6 +319,7 @@ class User extends Authenticatable
|
||||||
if ($this->admin >= 3) {
|
if ($this->admin >= 3) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -308,15 +331,16 @@ class User extends Authenticatable
|
||||||
if ($this->admin >= 4) {
|
if ($this->admin >= 4) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public function isUserHasApi()
|
public function isUserHasApi()
|
||||||
{
|
{
|
||||||
if ($this->id === 3) {
|
if ($this->id === 3) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -328,6 +352,7 @@ class User extends Authenticatable
|
||||||
if ($this->admin >= 5) {
|
if ($this->admin >= 5) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -339,15 +364,25 @@ class User extends Authenticatable
|
||||||
return $this->test_mode ? true : false;
|
return $this->test_mode ? true : false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
public function showSideNav()
|
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;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -361,6 +396,7 @@ class User extends Authenticatable
|
||||||
{
|
{
|
||||||
return ($this->active == 1 && $this->blocked == 0) ? true : false;
|
return ($this->active == 1 && $this->blocked == 0) ? true : false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isActiveAccount()
|
public function isActiveAccount()
|
||||||
{
|
{
|
||||||
return $this->payment_account ? Carbon::parse($this->payment_account)->gt(Carbon::now()) : false;
|
return $this->payment_account ? Carbon::parse($this->payment_account)->gt(Carbon::now()) : false;
|
||||||
|
|
@ -376,6 +412,7 @@ class User extends Authenticatable
|
||||||
if ($this->payment_account) {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -389,7 +426,7 @@ class User extends Authenticatable
|
||||||
return Carbon::now()->diffInDays(Carbon::parse($this->payment_account), false);
|
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());
|
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);
|
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());
|
return Carbon::parse($this->payment_shop)->modify($add)->format(\Util::formatDateTimeDB());
|
||||||
}
|
}
|
||||||
|
|
@ -423,6 +460,7 @@ class User extends Authenticatable
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -432,8 +470,9 @@ class User extends Authenticatable
|
||||||
public function getConfirmationDateFormat()
|
public function getConfirmationDateFormat()
|
||||||
{
|
{
|
||||||
if (! $this->attributes['confirmation_date']) {
|
if (! $this->attributes['confirmation_date']) {
|
||||||
return "";
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
return Carbon::parse($this->attributes['confirmation_date'])->format(\Util::formatDateTimeDB());
|
return Carbon::parse($this->attributes['confirmation_date'])->format(\Util::formatDateTimeDB());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -443,11 +482,12 @@ class User extends Authenticatable
|
||||||
public function getActiveDateFormat($time = true)
|
public function getActiveDateFormat($time = true)
|
||||||
{
|
{
|
||||||
if (! $this->attributes['active_date']) {
|
if (! $this->attributes['active_date']) {
|
||||||
return "";
|
return '';
|
||||||
}
|
}
|
||||||
if (! $time) {
|
if (! $time) {
|
||||||
return Carbon::parse($this->attributes['active_date'])->format(\Util::formatDateDB());
|
return Carbon::parse($this->attributes['active_date'])->format(\Util::formatDateDB());
|
||||||
}
|
}
|
||||||
|
|
||||||
return Carbon::parse($this->attributes['active_date'])->format(\Util::formatDateTimeDB());
|
return Carbon::parse($this->attributes['active_date'])->format(\Util::formatDateTimeDB());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -457,45 +497,48 @@ class User extends Authenticatable
|
||||||
public function getAgreementFormat()
|
public function getAgreementFormat()
|
||||||
{
|
{
|
||||||
if (! $this->attributes['agreement']) {
|
if (! $this->attributes['agreement']) {
|
||||||
return "";
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
return Carbon::parse($this->attributes['agreement'])->format(\Util::formatDateTimeDB());
|
return Carbon::parse($this->attributes['agreement'])->format(\Util::formatDateTimeDB());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getPaymentAccountDateFormat($time = true)
|
public function getPaymentAccountDateFormat($time = true)
|
||||||
{
|
{
|
||||||
if (! $this->attributes['payment_account']) {
|
if (! $this->attributes['payment_account']) {
|
||||||
return "";
|
return '';
|
||||||
}
|
}
|
||||||
if (! $time) {
|
if (! $time) {
|
||||||
return Carbon::parse($this->attributes['payment_account'])->format(\Util::formatDateDB());
|
return Carbon::parse($this->attributes['payment_account'])->format(\Util::formatDateDB());
|
||||||
}
|
}
|
||||||
|
|
||||||
return Carbon::parse($this->attributes['payment_account'])->format(\Util::formatDateTimeDB());
|
return Carbon::parse($this->attributes['payment_account'])->format(\Util::formatDateTimeDB());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getPaymentShopDateFormat($time = true)
|
public function getPaymentShopDateFormat($time = true)
|
||||||
{
|
{
|
||||||
if (! $this->attributes['payment_shop']) {
|
if (! $this->attributes['payment_shop']) {
|
||||||
return "";
|
return '';
|
||||||
}
|
}
|
||||||
if (! $time) {
|
if (! $time) {
|
||||||
return Carbon::parse($this->attributes['payment_shop'])->format(\Util::formatDateDB());
|
return Carbon::parse($this->attributes['payment_shop'])->format(\Util::formatDateDB());
|
||||||
}
|
}
|
||||||
|
|
||||||
return Carbon::parse($this->attributes['payment_shop'])->format(\Util::formatDateTimeDB());
|
return Carbon::parse($this->attributes['payment_shop'])->format(\Util::formatDateTimeDB());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getReleaseAccountFormat($time = true)
|
public function getReleaseAccountFormat($time = true)
|
||||||
{
|
{
|
||||||
if (! $this->attributes['release_account']) {
|
if (! $this->attributes['release_account']) {
|
||||||
return "";
|
return '';
|
||||||
}
|
}
|
||||||
if (! $time) {
|
if (! $time) {
|
||||||
return Carbon::parse($this->attributes['release_account'])->format(\Util::formatDateDB());
|
return Carbon::parse($this->attributes['release_account'])->format(\Util::formatDateDB());
|
||||||
}
|
}
|
||||||
|
|
||||||
return Carbon::parse($this->attributes['release_account'])->format(\Util::formatDateTimeDB());
|
return Carbon::parse($this->attributes['release_account'])->format(\Util::formatDateTimeDB());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public function setSetting(array $revisions, bool $save = true)
|
public function setSetting(array $revisions, bool $save = true)
|
||||||
{
|
{
|
||||||
if (! $this->settings) {
|
if (! $this->settings) {
|
||||||
|
|
@ -505,6 +548,7 @@ class User extends Authenticatable
|
||||||
if ($save) {
|
if ($save) {
|
||||||
$this->save();
|
$this->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -515,17 +559,19 @@ class User extends Authenticatable
|
||||||
|
|
||||||
public function getPaymentMethodsShort()
|
public function getPaymentMethodsShort()
|
||||||
{
|
{
|
||||||
$ret = "";
|
$ret = '';
|
||||||
if ($this->payment_methods !== null) {
|
if ($this->payment_methods !== null) {
|
||||||
foreach ($this->payment_methods as $payment_method) {
|
foreach ($this->payment_methods as $payment_method) {
|
||||||
if ($find = PaymentMethod::find($payment_method)) {
|
if ($find = PaymentMethod::find($payment_method)) {
|
||||||
$ret .= $find->short . " | ";
|
$ret .= $find->short.' | ';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$ret = rtrim($ret, " | ");
|
$ret = rtrim($ret, ' | ');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $ret;
|
return $ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
|
|
@ -533,20 +579,21 @@ class User extends Authenticatable
|
||||||
{
|
{
|
||||||
if ($this->account && $this->account->country_id) {
|
if ($this->account && $this->account->country_id) {
|
||||||
$code = $this->account->country->code;
|
$code = $this->account->country->code;
|
||||||
if ($code == "FR") {
|
if ($code == 'FR') {
|
||||||
return 'fr';
|
return 'fr';
|
||||||
}
|
}
|
||||||
if ($code == "CH") {
|
if ($code == 'CH') {
|
||||||
return 'de';
|
return 'de';
|
||||||
}
|
}
|
||||||
if ($code == "NL") {
|
if ($code == 'NL') {
|
||||||
return 'nl';
|
return 'nl';
|
||||||
}
|
}
|
||||||
if ($code == "DE") {
|
if ($code == 'DE') {
|
||||||
return 'de';
|
return 'de';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "de";
|
|
||||||
|
return 'de';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -602,6 +649,7 @@ class User extends Authenticatable
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
44
database/factories/IncentiveFactory.php
Normal file
44
database/factories/IncentiveFactory.php
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Incentive;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
class IncentiveFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = Incentive::class;
|
||||||
|
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => 'Incentive '.$this->faker->city().' '.$this->faker->year(),
|
||||||
|
'description' => $this->faker->sentence(),
|
||||||
|
'terms' => $this->faker->paragraph(),
|
||||||
|
'qualification_start' => '2026-04-01',
|
||||||
|
'qualification_end' => '2026-07-31',
|
||||||
|
'calculation_end' => '2026-08-31',
|
||||||
|
'points_partner_onetime' => 600,
|
||||||
|
'points_abo_onetime' => 400,
|
||||||
|
'min_direct_partners' => 4,
|
||||||
|
'min_customer_abos' => 6,
|
||||||
|
'max_winners' => 30,
|
||||||
|
'status' => 1, // active
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function draft(): static
|
||||||
|
{
|
||||||
|
return $this->state(['status' => 0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function active(): static
|
||||||
|
{
|
||||||
|
return $this->state(['status' => 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function closed(): static
|
||||||
|
{
|
||||||
|
return $this->state(['status' => 2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
database/factories/IncentiveNewAboFactory.php
Normal file
21
database/factories/IncentiveNewAboFactory.php
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\IncentiveNewAbo;
|
||||||
|
use App\Models\IncentiveParticipant;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
class IncentiveNewAboFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = IncentiveNewAbo::class;
|
||||||
|
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'participant_id' => IncentiveParticipant::factory(),
|
||||||
|
'user_abo_id' => $this->faker->randomNumber(3),
|
||||||
|
'activated_at' => now(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
22
database/factories/IncentiveNewPartnerFactory.php
Normal file
22
database/factories/IncentiveNewPartnerFactory.php
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\IncentiveNewPartner;
|
||||||
|
use App\Models\IncentiveParticipant;
|
||||||
|
use App\User;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
class IncentiveNewPartnerFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = IncentiveNewPartner::class;
|
||||||
|
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'participant_id' => IncentiveParticipant::factory(),
|
||||||
|
'user_id' => fn () => User::inRandomOrder()->first()?->id ?? 1,
|
||||||
|
'registered_at' => now(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
46
database/factories/IncentiveParticipantFactory.php
Normal file
46
database/factories/IncentiveParticipantFactory.php
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Incentive;
|
||||||
|
use App\Models\IncentiveParticipant;
|
||||||
|
use App\User;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
class IncentiveParticipantFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = IncentiveParticipant::class;
|
||||||
|
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'incentive_id' => Incentive::factory(),
|
||||||
|
'user_id' => fn () => User::inRandomOrder()->first()?->id ?? 1,
|
||||||
|
'accepted_terms_at' => now(),
|
||||||
|
'total_points' => 0,
|
||||||
|
'qualified_partners' => 0,
|
||||||
|
'qualified_abos' => 0,
|
||||||
|
'is_qualified' => false,
|
||||||
|
'rank' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function qualified(): static
|
||||||
|
{
|
||||||
|
return $this->state([
|
||||||
|
'is_qualified' => true,
|
||||||
|
'qualified_partners' => 4,
|
||||||
|
'qualified_abos' => 6,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automatisch angelegt, Teilnahmebedingungen noch nicht akzeptiert (anonym in der Rangliste).
|
||||||
|
*/
|
||||||
|
public function unconfirmed(): static
|
||||||
|
{
|
||||||
|
return $this->state([
|
||||||
|
'accepted_terms_at' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
database/factories/IncentivePointsLogFactory.php
Normal file
51
database/factories/IncentivePointsLogFactory.php
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\IncentiveParticipant;
|
||||||
|
use App\Models\IncentivePointsLog;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
class IncentivePointsLogFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = IncentivePointsLog::class;
|
||||||
|
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'participant_id' => IncentiveParticipant::factory(),
|
||||||
|
'type' => 'partner',
|
||||||
|
'source_type' => 'App\\User',
|
||||||
|
'source_id' => 1,
|
||||||
|
'source_label' => $this->faker->name(),
|
||||||
|
'month' => 4,
|
||||||
|
'year' => 2026,
|
||||||
|
'points_onetime' => 600,
|
||||||
|
'points_accumulated' => 0,
|
||||||
|
'is_storno' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function partner(): static
|
||||||
|
{
|
||||||
|
return $this->state([
|
||||||
|
'type' => 'partner',
|
||||||
|
'points_onetime' => 600,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function abo(): static
|
||||||
|
{
|
||||||
|
return $this->state([
|
||||||
|
'type' => 'abo',
|
||||||
|
'points_onetime' => 400,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function storno(): static
|
||||||
|
{
|
||||||
|
return $this->state([
|
||||||
|
'is_storno' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
class CreateCountriesTable extends Migration
|
class CreateCountriesTable extends Migration
|
||||||
{
|
{
|
||||||
|
|
@ -35,13 +35,10 @@ class CreateCountriesTable extends Migration
|
||||||
$table->boolean('currency_calc')->default(false);
|
$table->boolean('currency_calc')->default(false);
|
||||||
$table->decimal('currency_faktor', 4, 2)->nullable();
|
$table->decimal('currency_faktor', 4, 2)->nullable();
|
||||||
|
|
||||||
$table->boolean('active')->default(true);
|
|
||||||
$table->text('trans_name')->nullable();
|
$table->text('trans_name')->nullable();
|
||||||
$table->text('attr')->nullable();
|
$table->text('attr')->nullable();
|
||||||
|
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
|
|
||||||
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,12 @@ return new class extends Migration
|
||||||
*/
|
*/
|
||||||
public function up(): void
|
public function up(): void
|
||||||
{
|
{
|
||||||
|
if (! Schema::hasColumn('dhl_package_shipments', 'routing_code')) {
|
||||||
Schema::table('dhl_package_shipments', function (Blueprint $table) {
|
Schema::table('dhl_package_shipments', function (Blueprint $table) {
|
||||||
$table->string('routing_code')->nullable()->after('dhl_shipment_no');
|
$table->string('routing_code')->nullable()->after('dhl_shipment_no');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reverse the migrations.
|
* Reverse the migrations.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('incentives', function (Blueprint $table) {
|
||||||
|
$table->increments('id');
|
||||||
|
|
||||||
|
$table->string('name', 255);
|
||||||
|
$table->string('slug', 255)->unique();
|
||||||
|
$table->text('description')->nullable();
|
||||||
|
$table->string('image', 255)->nullable()->comment('Statisches Bild Dateiname');
|
||||||
|
$table->text('terms')->nullable()->comment('Teilnahmebedingungen');
|
||||||
|
|
||||||
|
$table->date('qualification_start')->comment('Beginn Qualifikationszeitraum');
|
||||||
|
$table->date('qualification_end')->comment('Ende Qualifikationszeitraum');
|
||||||
|
$table->date('calculation_end')->comment('Ende Punkteberechnung (inkl. Verlaengerung)');
|
||||||
|
|
||||||
|
$table->unsignedInteger('points_partner_onetime')->default(600)->comment('Einmalpunkte pro Neupartner');
|
||||||
|
$table->unsignedInteger('points_abo_onetime')->default(400)->comment('Einmalpunkte pro Kundenabo');
|
||||||
|
$table->unsignedInteger('min_direct_partners')->default(4)->comment('Mindestanzahl direkte Teampartner');
|
||||||
|
$table->unsignedInteger('min_customer_abos')->default(6)->comment('Mindestanzahl Kundenabos');
|
||||||
|
$table->unsignedInteger('max_winners')->default(30)->comment('Maximale Anzahl Gewinner');
|
||||||
|
|
||||||
|
$table->unsignedTinyInteger('status')->default(0)->index()->comment('0=draft, 1=active, 2=closed');
|
||||||
|
|
||||||
|
$table->timestamps();
|
||||||
|
$table->softDeletes();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('incentives');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('incentive_participants', function (Blueprint $table) {
|
||||||
|
$table->increments('id');
|
||||||
|
|
||||||
|
$table->unsignedInteger('incentive_id');
|
||||||
|
$table->foreign('incentive_id')
|
||||||
|
->references('id')
|
||||||
|
->on('incentives')
|
||||||
|
->onDelete('cascade');
|
||||||
|
|
||||||
|
$table->unsignedInteger('user_id');
|
||||||
|
$table->foreign('user_id')
|
||||||
|
->references('id')
|
||||||
|
->on('users')
|
||||||
|
->onDelete('cascade');
|
||||||
|
|
||||||
|
$table->datetime('accepted_terms_at')->comment('Zeitpunkt Teilnahme-Bestaetigung');
|
||||||
|
|
||||||
|
$table->integer('total_points')->default(0)->comment('Gesamtpunkte (Cache fuer Ranking)');
|
||||||
|
$table->unsignedInteger('qualified_partners')->default(0)->comment('Anzahl qualifizierter Neupartner');
|
||||||
|
$table->unsignedInteger('qualified_abos')->default(0)->comment('Anzahl qualifizierter Kundenabos');
|
||||||
|
$table->boolean('is_qualified')->default(false)->index()->comment('Mindestqualifikation erreicht');
|
||||||
|
$table->unsignedInteger('rank')->nullable()->comment('Aktuelle Platzierung');
|
||||||
|
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['incentive_id', 'user_id'], 'incentive_user_unique');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('incentive_participants');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('incentive_points_log', function (Blueprint $table) {
|
||||||
|
$table->increments('id');
|
||||||
|
|
||||||
|
$table->unsignedInteger('participant_id');
|
||||||
|
$table->foreign('participant_id')
|
||||||
|
->references('id')
|
||||||
|
->on('incentive_participants')
|
||||||
|
->onDelete('cascade');
|
||||||
|
|
||||||
|
$table->string('type', 10)->index()->comment('partner oder abo');
|
||||||
|
|
||||||
|
$table->string('source_type', 100)->comment('Polymorphic: App\\User oder App\\Models\\UserAbo');
|
||||||
|
$table->unsignedInteger('source_id')->comment('ID des Neupartners oder des Abos');
|
||||||
|
$table->string('source_label', 255)->comment('Name/Bezeichnung fuer Anzeige');
|
||||||
|
|
||||||
|
$table->unsignedTinyInteger('month')->comment('Bezugsmonat');
|
||||||
|
$table->unsignedSmallInteger('year')->comment('Bezugsjahr');
|
||||||
|
|
||||||
|
$table->integer('points_onetime')->default(0)->comment('Einmalpunkte (600 bzw. 400)');
|
||||||
|
$table->integer('points_accumulated')->default(0)->comment('Akkumulierte Punkte des Monats');
|
||||||
|
|
||||||
|
$table->boolean('is_storno')->default(false)->index()->comment('Storno-Eintrag');
|
||||||
|
$table->unsignedInteger('storno_of_id')->nullable()->comment('Referenz auf stornierten Eintrag');
|
||||||
|
$table->foreign('storno_of_id')
|
||||||
|
->references('id')
|
||||||
|
->on('incentive_points_log');
|
||||||
|
|
||||||
|
$table->unsignedInteger('user_sales_volume_id')->nullable();
|
||||||
|
$table->foreign('user_sales_volume_id')
|
||||||
|
->references('id')
|
||||||
|
->on('user_sales_volumes');
|
||||||
|
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['participant_id', 'type']);
|
||||||
|
$table->index(['source_type', 'source_id']);
|
||||||
|
$table->index(['month', 'year']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('incentive_points_log');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('incentive_new_partners', function (Blueprint $table) {
|
||||||
|
$table->increments('id');
|
||||||
|
|
||||||
|
$table->unsignedInteger('participant_id');
|
||||||
|
$table->foreign('participant_id')
|
||||||
|
->references('id')
|
||||||
|
->on('incentive_participants')
|
||||||
|
->onDelete('cascade');
|
||||||
|
|
||||||
|
$table->unsignedInteger('user_id')->comment('Der neue Partner');
|
||||||
|
$table->foreign('user_id')
|
||||||
|
->references('id')
|
||||||
|
->on('users')
|
||||||
|
->onDelete('cascade');
|
||||||
|
|
||||||
|
$table->datetime('registered_at')->comment('Registrierungsdatum des Partners');
|
||||||
|
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['participant_id', 'user_id'], 'participant_partner_unique');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('incentive_new_partners');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('incentive_new_abos', function (Blueprint $table) {
|
||||||
|
$table->increments('id');
|
||||||
|
|
||||||
|
$table->unsignedInteger('participant_id');
|
||||||
|
$table->foreign('participant_id')
|
||||||
|
->references('id')
|
||||||
|
->on('incentive_participants')
|
||||||
|
->onDelete('cascade');
|
||||||
|
|
||||||
|
$table->unsignedInteger('user_abo_id')->comment('Das Kundenabo');
|
||||||
|
$table->foreign('user_abo_id')
|
||||||
|
->references('id')
|
||||||
|
->on('user_abos')
|
||||||
|
->onDelete('cascade');
|
||||||
|
|
||||||
|
$table->datetime('activated_at')->comment('Aktivierungsdatum des Abos');
|
||||||
|
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['participant_id', 'user_abo_id'], 'participant_abo_unique');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('incentive_new_abos');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('user_sales_volumes', function (Blueprint $table) {
|
||||||
|
if (! Schema::hasColumn('user_sales_volumes', 'info')) {
|
||||||
|
$table->string('info', 255)->nullable()->after('message');
|
||||||
|
}
|
||||||
|
if (! Schema::hasColumn('user_sales_volumes', 'month_KP_points')) {
|
||||||
|
$table->decimal('month_KP_points', 13, 2)->nullable()->after('month_shop_points');
|
||||||
|
}
|
||||||
|
if (! Schema::hasColumn('user_sales_volumes', 'month_TP_points')) {
|
||||||
|
$table->decimal('month_TP_points', 13, 2)->nullable()->after('month_KP_points');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('user_sales_volumes', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['info', 'month_KP_points', 'month_TP_points']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('incentives', function (Blueprint $table) {
|
||||||
|
$table->json('trans_name')->nullable()->after('name');
|
||||||
|
$table->json('trans_description')->nullable()->after('description');
|
||||||
|
$table->json('trans_terms')->nullable()->after('terms');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('incentives', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['trans_name', 'trans_description', 'trans_terms']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('incentives', function (Blueprint $table) {
|
||||||
|
$table->string('subtitle')->nullable()->after('name');
|
||||||
|
$table->json('trans_subtitle')->nullable()->after('trans_name');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('incentives', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['subtitle', 'trans_subtitle']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('incentive_points_log', function (Blueprint $table) {
|
||||||
|
$table->unsignedInteger('incentive_new_partner_id')->nullable()->after('user_sales_volume_id');
|
||||||
|
$table->unsignedInteger('incentive_new_abo_id')->nullable()->after('incentive_new_partner_id');
|
||||||
|
|
||||||
|
$table->foreign('incentive_new_partner_id')
|
||||||
|
->references('id')
|
||||||
|
->on('incentive_new_partners')
|
||||||
|
->nullOnDelete();
|
||||||
|
|
||||||
|
$table->foreign('incentive_new_abo_id')
|
||||||
|
->references('id')
|
||||||
|
->on('incentive_new_abos')
|
||||||
|
->nullOnDelete();
|
||||||
|
|
||||||
|
$table->index('incentive_new_partner_id');
|
||||||
|
$table->index('incentive_new_abo_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('incentive_points_log', function (Blueprint $table) {
|
||||||
|
$table->dropForeign(['incentive_new_partner_id']);
|
||||||
|
$table->dropForeign(['incentive_new_abo_id']);
|
||||||
|
$table->dropColumn(['incentive_new_partner_id', 'incentive_new_abo_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('incentive_participants', function (Blueprint $table) {
|
||||||
|
$table->datetime('accepted_terms_at')->nullable()->change()->comment('Zeitpunkt Teilnahme-Bestaetigung; null = automatisch erfasst, noch nicht zugestimmt');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('incentive_participants', function (Blueprint $table) {
|
||||||
|
$table->datetime('accepted_terms_at')->nullable(false)->change()->comment('Zeitpunkt Teilnahme-Bestaetigung');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('abo_chart_snapshots', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedBigInteger('user_id');
|
||||||
|
$table->string('scope', 50); // 'ot' | 'team_abos' | 'team_cust_abos'
|
||||||
|
$table->unsignedSmallInteger('year');
|
||||||
|
$table->unsignedTinyInteger('month');
|
||||||
|
$table->unsignedInteger('count')->default(0);
|
||||||
|
$table->timestamp('calculated_at');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
// Einmaliger Eintrag pro User/Scope/Monat – verhindert Doppeleinträge
|
||||||
|
$table->unique(['user_id', 'scope', 'year', 'month']);
|
||||||
|
$table->index(['user_id', 'scope', 'year']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('abo_chart_snapshots');
|
||||||
|
}
|
||||||
|
};
|
||||||
53
database/seeders/IncentiveParticipantSeeder.php
Normal file
53
database/seeders/IncentiveParticipantSeeder.php
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\IncentiveParticipant;
|
||||||
|
use App\User;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class IncentiveParticipantSeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Spielt Teilnehmer fuer Incentive ID 1 ein.
|
||||||
|
* Nimmt aktive Berater (mit m_sponsor) sortiert nach Anzahl direkter Partner.
|
||||||
|
* Bereits bestehende Teilnehmer werden uebersprungen.
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$incentive_id = 1;
|
||||||
|
$limit = 50;
|
||||||
|
|
||||||
|
// Bereits teilnehmende User-IDs
|
||||||
|
$existing_user_ids = IncentiveParticipant::where('incentive_id', $incentive_id)
|
||||||
|
->pluck('user_id')
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
// Aktive Berater mit den meisten direkten Partnern holen
|
||||||
|
$consultants = User::where('active', 1)
|
||||||
|
->whereNotNull('m_sponsor')
|
||||||
|
->whereNotIn('id', $existing_user_ids)
|
||||||
|
->select('users.*')
|
||||||
|
->selectSub(
|
||||||
|
DB::table('users as u2')->selectRaw('COUNT(*)')->whereColumn('u2.m_sponsor', 'users.id'),
|
||||||
|
'direct_partners_count'
|
||||||
|
)
|
||||||
|
->orderByDesc('direct_partners_count')
|
||||||
|
->limit($limit)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$count = 0;
|
||||||
|
foreach ($consultants as $consultant) {
|
||||||
|
IncentiveParticipant::create([
|
||||||
|
'incentive_id' => $incentive_id,
|
||||||
|
'user_id' => $consultant->id,
|
||||||
|
'accepted_terms_at' => Carbon::now(),
|
||||||
|
]);
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->command->info("Incentive #{$incentive_id}: {$count} Teilnehmer hinzugefuegt (bestehende uebersprungen).");
|
||||||
|
}
|
||||||
|
}
|
||||||
127
dev/2026-03-12/tasks.md
Normal file
127
dev/2026-03-12/tasks.md
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
# Korrekturen & Verbesserungen - 12.03.2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Bug-Fix: Falsche Zahlungsbeträge bei Abo-Bestellungen
|
||||||
|
|
||||||
|
**Datei:** `app/Cron/UserMakeOrder.php` (Zeile 71)
|
||||||
|
|
||||||
|
**Problem:** Bei der automatischen Abo-Zahlungsausführung wurde `subtotal_ws` (Nettobetrag + Versand) statt `total_shipping` (Bruttobetrag + Versand) als Zahlungsbetrag an Payone gesendet. Dadurch wurde bei Kunden mit Umsatzsteuer nur der Nettobetrag eingezogen - die MwSt fehlte.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
```php
|
||||||
|
// Vorher (falsch):
|
||||||
|
$amount = $this->shopping_order->subtotal_ws * 100;
|
||||||
|
|
||||||
|
// Nachher (korrekt):
|
||||||
|
$amount = $this->shopping_order->total_shipping * 100;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Auswirkung:** Betrifft alle Abo-Bestellungen seit Go-Live, bei denen Umsatzsteuer anfällt. Bei Nettobeträgen (Drittländer, USt-ID hinterlegt) war der Betrag zufällig korrekt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. SysAdmin Tool: Abo-Bestellungen Zahlungsdifferenzen
|
||||||
|
|
||||||
|
**Neue Dateien:**
|
||||||
|
- `app/Services/SyS/AboOrdersOverview.php`
|
||||||
|
- `resources/views/sys/tools/abo-orders-overview.blade.php`
|
||||||
|
|
||||||
|
**Geänderte Dateien:**
|
||||||
|
- `app/Http/Controllers/SyS/SysController.php` (neuer case `abo_orders_overview`)
|
||||||
|
- `resources/views/sys/index.blade.php` (neuer Menüpunkt)
|
||||||
|
|
||||||
|
**Beschreibung:** Übersichtstool unter `sysadmin/tool/abo_orders_overview` zur Nachverfolgung der durch den Bug betroffenen Abo-Bestellungen. Zeigt pro Bestellung:
|
||||||
|
- Netto+Versand (`subtotal_ws`) = was eingezogen wurde
|
||||||
|
- MwSt (`tax`) = was gefehlt hat
|
||||||
|
- Brutto+Versand (`total_shipping`) = was hätte eingezogen werden sollen
|
||||||
|
- Differenz = fehlender Betrag (rot markiert)
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Filter: Alle / Berater / Kunden (Button-Gruppe)
|
||||||
|
- Summary-Karten: Gesamtanzahl, betroffene Bestellungen, Gesamtfehlbetrag
|
||||||
|
- Betroffene Zeilen rot hervorgehoben
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Bug-Fix: Abo-Einstellungen - Vergleichsoperator
|
||||||
|
|
||||||
|
**Datei:** `app/Repositories/AboRepository.php` (Zeile 66)
|
||||||
|
|
||||||
|
**Problem:** Bei der Reaktivierung eines pausierten Abos wurde statt eines Vergleichs (`==`) eine Zuweisung (`=`) verwendet. Dadurch wurde der Status immer auf 6 gesetzt, bevor er auf 2 geändert wurde.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
```php
|
||||||
|
// Vorher (falsch - Zuweisung):
|
||||||
|
if ($this->model->status = 6)
|
||||||
|
|
||||||
|
// Nachher (korrekt - Vergleich):
|
||||||
|
if ($this->model->status == 6)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Abo-Liefertag: Validierung und Sperren
|
||||||
|
|
||||||
|
**Datei:** `app/Repositories/AboRepository.php`
|
||||||
|
|
||||||
|
**Problem:** Beim Ändern des Abo-Liefertags konnte ein Datum gewählt werden, das nur wenige Tage entfernt liegt. Da Pakete vorgepackt werden, muss ein Mindestabstand eingehalten werden.
|
||||||
|
|
||||||
|
**Lösung:** Zwei Klassenkonstanten steuern die Sperren:
|
||||||
|
|
||||||
|
```php
|
||||||
|
private const LOCK_DAYS_CHANGE = 10; // Liefertag-Änderung
|
||||||
|
private const LOCK_DAYS_PAUSE_CANCEL = 3; // Pausieren/Kündigen
|
||||||
|
```
|
||||||
|
|
||||||
|
### Regeln:
|
||||||
|
|
||||||
|
| Aktion | Sperre | Beschreibung |
|
||||||
|
|--------|--------|--------------|
|
||||||
|
| Liefertag ändern | < 10 Tage vor aktueller Ausführung | `error_change_locked` - Pakete werden vorgepackt |
|
||||||
|
| Neuen Liefertag wählen | Neues Datum < 10 Tage entfernt | `error_abo_interval_too_soon` - Berechnung via `setNextDate()` |
|
||||||
|
| Abo pausieren | < 3 Tage vor Ausführung | `error_pause_locked` |
|
||||||
|
| Abo kündigen | < 3 Tage vor Ausführung | `error_cancel_locked` |
|
||||||
|
|
||||||
|
### Validierungslogik:
|
||||||
|
1. Aktuelles `next_date` prüfen: Wenn < 10 Tage entfernt → komplett gesperrt
|
||||||
|
2. Neues Datum berechnen via `AboHelper::setNextDate()` und prüfen ob >= 10 Tage entfernt
|
||||||
|
3. Nach erfolgreichem Speichern: Orange Alert mit exaktem nächsten Ausführungsdatum
|
||||||
|
|
||||||
|
### Beispiele (heute = 12. März):
|
||||||
|
| Von | Auf | Berechnet | Tage | Ergebnis |
|
||||||
|
|-----|-----|-----------|------|----------|
|
||||||
|
| 5. | 20. | 20. März | 8 | blockiert |
|
||||||
|
| 5. | 25. | 25. März | 13 | erlaubt |
|
||||||
|
| 20. | 10. | 10. April | ~29 | erlaubt (nächster Monat) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Warnungen bei Abo-Erstellung (Bestellformular)
|
||||||
|
|
||||||
|
**Geänderte Dateien:**
|
||||||
|
- `app/Services/HTMLHelper.php` (`getAboDeliveryOptions`)
|
||||||
|
- `resources/views/user/order/yard_view_form.blade.php`
|
||||||
|
- `resources/views/portal/abo/_create_check.blade.php`
|
||||||
|
|
||||||
|
**Beschreibung:** Beim Erstellen eines neuen Abos zeigt das Liefertag-Dropdown eine dynamische Warnung (orange) an, wenn die erste Abo-Ausführung weniger als 20 Tage entfernt ist. Die Warnung aktualisiert sich beim Wechsel des Liefertags.
|
||||||
|
|
||||||
|
**Umsetzung:**
|
||||||
|
- `HTMLHelper::getAboDeliveryOptions()` gibt pro Option `data-days` und `data-date` Attribute aus
|
||||||
|
- JavaScript im Formular prüft beim Ändern der Auswahl die Tage und zeigt/versteckt die Warnung
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Neue Übersetzungen (DE/EN/ES)
|
||||||
|
|
||||||
|
**Dateien:** `resources/lang/{de,en,es}/abo.php`
|
||||||
|
|
||||||
|
| Key | Beschreibung |
|
||||||
|
|-----|-------------|
|
||||||
|
| `warning_next_date_soon` | Warnung: Nächste Ausführung < 20 Tage (Backend-Flash) |
|
||||||
|
| `warning_next_date_soon_select` | Warnung: Nächste Ausführung < 20 Tage (Frontend-Select) |
|
||||||
|
| `warning_next_date_info` | Info nach Speichern: Nächste Ausführung in X Tagen am Datum |
|
||||||
|
| `error_change_locked` | Fehler: Änderung gesperrt (< 10 Tage) |
|
||||||
|
| `error_abo_interval_too_soon` | Fehler: Neuer Liefertag zu nah (< 10 Tage) |
|
||||||
|
| `error_cancel_locked` | Fehler: Kündigung gesperrt (< 3 Tage) |
|
||||||
|
| `error_pause_locked` | Fehler: Pausierung gesperrt (< 3 Tage) |
|
||||||
BIN
dev/Incentive-Modul/Incentive-montenegro.pdf
Normal file
BIN
dev/Incentive-Modul/Incentive-montenegro.pdf
Normal file
Binary file not shown.
92
dev/Incentive-Modul/README.md
Normal file
92
dev/Incentive-Modul/README.md
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
# Incentive-Modul (CRM)
|
||||||
|
|
||||||
|
Übersichtliche Beschreibung des **wiederverwendbaren Incentive-Systems** in mivita: zeitlich begrenzte Challenges (z. B. Reise-Anreize), eigenes Punkte- und Ranking-Modell, **getrennt** von MLM-Provisionsberechnung (`BusinessPlan` / `TreeCalcBot`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zweck
|
||||||
|
|
||||||
|
- Mehrere **Incentives** parallel oder nacheinander (pro Jahr/Kampagne anlegbar).
|
||||||
|
- Berater **opt-in** mit Teilnahmebedingungen; **Punkte**, **Mindestqualifikation** (Partner + Kundenabos) und **Top-N-Gewinner** sind pro Incentive konfigurierbar.
|
||||||
|
- **Transparenz** für Berater: Live-Rangliste, Detailansicht mit Aufschlüsselung nach Neupartnern und Kundenabos (inkl. Monaten).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architektur in Kürze
|
||||||
|
|
||||||
|
| Schicht | Inhalt |
|
||||||
|
|--------|--------|
|
||||||
|
| **Konfiguration** | `incentives` – Zeiträume, Punkte, Mindestwerte, `max_winners`, Status, Slug, Übersetzungen |
|
||||||
|
| **Teilnahme** | `incentive_participants` – Opt-in, aggregierte Werte, Rang, Qualifikations-Flags |
|
||||||
|
| **Tracking** | `incentive_new_partners`, `incentive_new_abos` – welche Neupartner/Abos einem Teilnehmer zugeordnet sind |
|
||||||
|
| **Historie** | `incentive_points_log` – Einmal- und Akkumpunkte, optional Verknüpfung zu `UserSalesVolume`, Stornos |
|
||||||
|
|
||||||
|
**Kernservice:** `App\Services\Incentive\IncentiveTracker` – wird aus Zahlungsfluss, Rechnung/Sales Volume und Storno aufgerufen.
|
||||||
|
|
||||||
|
**Batch:** `php artisan incentive:calculate` (`IncentiveCalculate`) + optional `IncentiveCalculationService` / Neuaufbau aus Quellen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Datenfluss (Events)
|
||||||
|
|
||||||
|
1. **Neupartner mit Starterpaket** – bezahlte **Wizard-Registrierung** (`payment_for = 1`), Bestellung enthält mindestens ein Produkt **ohne** „reine Mitgliedschaft“ (`Product::is_membership_only = false`). → Eintrag in `incentive_new_partners`, Einmalpunkte im Log, Neuberechnung.
|
||||||
|
2. **Abo aktiviert** – nach `AboHelper::setAboActive`: **Kundenabo** `is_for = 'ot'` (Berater = `member_id`) **oder Berater-Eigenabo** `is_for = 'me'` (Berater = `user_id`). → `incentive_new_abos`, Einmalpunkte, Neuberechnung. Beim Neuaufbau zählen Kundenabos über `member_id` im Qualifikationszeitraum; Eigenabos (`me`) neu im Zeitraum oder **bereits vor Qualifikationsbeginn** aktiv (dann Einmalpunkte mit Wirkung ab Qualifikationsstart).
|
||||||
|
3. **Akkumulierte Punkte** – bei neuer **`UserSalesVolume`**-Zeile (Rechnung): Umsatzpunkte von **getrackten Neupartnern** (SV gehört zur User-ID des Partners) bzw. im **Neuabo-Pfad** über Bestellung/Kundenkontext. → Log `points_accumulated`, Neuberechnung.
|
||||||
|
4. **Storno** – Gegenbuchung im Log, Neuberechnung.
|
||||||
|
|
||||||
|
Details und Integrationsstellen: **`entwicklungsplan.md`**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI
|
||||||
|
|
||||||
|
| Bereich | Beschreibung |
|
||||||
|
|---------|----------------|
|
||||||
|
| **Admin** | CRUD Incentives, Konfiguration, Teilnehmer-Ranking-Tabelle |
|
||||||
|
| **Berater** | Teaser/Show (Info, Live-Ranking, Teilnahme), **Details** mit Monatsaufschlüsselung |
|
||||||
|
| **Navigation** | Einträge z. B. über Dashboard / Sidenav (je nach Rolle) |
|
||||||
|
|
||||||
|
Content-Texte (Marketing) für eine konkrete Kampagne liegen separat in **`site.md`**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wichtige Geschäftsregeln (implementiert)
|
||||||
|
|
||||||
|
- **Rangliste (User):** Sortierung: zuerst **qualifizierte** Teilnehmer (Mindest-Partner/Abos), dann nach **Rang**, Teilnehmer **ohne** Rang unten, Tiebreaker **Gesamtpunkte**; Anzeige begrenzt auf **`max_winners`**; optional nur Teilnehmer mit **Aktivität** (Partner, Abo oder Punkte > 0) – Scope `withRankingActivity()`.
|
||||||
|
- **Neupartner zählen nur mit Starterpaket** – keine reine Mitgliedschafts-Bestellung allein; technisch: `ShoppingOrder::wherePaidRegistrationIncludesStarterKit()` / `qualifiesForIncentiveTrackedPartner()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dokumente in diesem Ordner
|
||||||
|
|
||||||
|
| Datei | Inhalt |
|
||||||
|
|-------|--------|
|
||||||
|
| **README.md** (diese Datei) | Modulübersicht |
|
||||||
|
| **entwicklungsplan.md** | Datenmodell, Services, Hooks, Phasen, Dateiliste, Architektur-Tracking |
|
||||||
|
| **tasks.md** | Anforderungen, Aufgaben- und Statusübersicht |
|
||||||
|
| **site.md** | Vorgefertigte Textbausteine / Nutzungsbedingungen (Content Montenegro 2026) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Relevante Code-Pfade (Referenz)
|
||||||
|
|
||||||
|
```
|
||||||
|
app/Models/Incentive.php
|
||||||
|
app/Models/IncentiveParticipant.php
|
||||||
|
app/Models/IncentivePointsLog.php
|
||||||
|
app/Models/IncentiveNewPartner.php
|
||||||
|
app/Models/IncentiveNewAbo.php
|
||||||
|
app/Services/Incentive/IncentiveTracker.php
|
||||||
|
app/Services/Incentive/IncentiveCalculationService.php
|
||||||
|
app/Services/Incentive/IncentivePointsLogRepairService.php
|
||||||
|
app/Http/Controllers/Admin/IncentiveController.php
|
||||||
|
app/Http/Controllers/User/IncentiveController.php
|
||||||
|
resources/lang/{de,en,es}/incentive.php
|
||||||
|
tests/Feature/Incentive/
|
||||||
|
tests/Unit/Incentive/
|
||||||
|
tests/Unit/Services/Incentive/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Letzte inhaltliche Aktualisierung der Modul-Doku: März 2026.*
|
||||||
530
dev/Incentive-Modul/entwicklungsplan.md
Normal file
530
dev/Incentive-Modul/entwicklungsplan.md
Normal file
|
|
@ -0,0 +1,530 @@
|
||||||
|
# Entwicklungsplan: Incentive-System (Montenegro 2026)
|
||||||
|
|
||||||
|
**Erstellt**: 17.03.2026
|
||||||
|
**Frist**: Fertigstellung innerhalb 2 Wochen (bis 31.03.2026)
|
||||||
|
**Ziel**: Wiederverwendbares Incentive-/Gewinnspiel-System, losgelöst von allen anderen Berechnungen.
|
||||||
|
|
||||||
|
**Kurzüberblick für Einstieg:** siehe **`README.md`** im gleichen Ordner.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Datenbank-Design (3 neue Tabellen)
|
||||||
|
|
||||||
|
### Tabelle `incentives` – Master-Konfiguration
|
||||||
|
|
||||||
|
| Feld | Typ | Beschreibung |
|
||||||
|
|-------------------------|------------------|--------------------------------------------------|
|
||||||
|
| id | bigint PK | |
|
||||||
|
| name | string | z.B. "Montenegro Incentive 2026" |
|
||||||
|
| slug | string unique | URL-Slug (Eloquent-Sluggable) |
|
||||||
|
| description | text nullable | Erklärungstext für Berater-Seite |
|
||||||
|
| image | string nullable | Dateiname des statischen Bildes |
|
||||||
|
| terms | text nullable | Teilnahmebedingungen |
|
||||||
|
| qualification_start | date | Beginn Qualifikationszeitraum (z.B. 2026-04-01) |
|
||||||
|
| qualification_end | date | Ende Qualifikationszeitraum (z.B. 2026-07-31) |
|
||||||
|
| calculation_end | date | Ende Punkteberechnung (z.B. 2026-08-31) |
|
||||||
|
| points_partner_onetime | int default 600 | Einmalpunkte pro Neupartner |
|
||||||
|
| points_abo_onetime | int default 400 | Einmalpunkte pro Kundenabo |
|
||||||
|
| min_direct_partners | int default 4 | Mindestanzahl direkte Teampartner |
|
||||||
|
| min_customer_abos | int default 6 | Mindestanzahl Kundenabos |
|
||||||
|
| max_winners | int default 30 | Maximale Anzahl Gewinner |
|
||||||
|
| status | enum | draft / active / closed |
|
||||||
|
| timestamps | | |
|
||||||
|
| soft_deletes | | |
|
||||||
|
|
||||||
|
### Tabelle `incentive_participants` – Opt-in + aggregierte Werte
|
||||||
|
|
||||||
|
| Feld | Typ | Beschreibung |
|
||||||
|
|----------------------|------------------|-------------------------------------------|
|
||||||
|
| id | bigint PK | |
|
||||||
|
| incentive_id | FK | |
|
||||||
|
| user_id | FK | Berater |
|
||||||
|
| accepted_terms_at | datetime | Zeitpunkt der Teilnahme-Bestätigung |
|
||||||
|
| total_points | int default 0 | Gesamtpunkte (Cache für Ranking) |
|
||||||
|
| qualified_partners | int default 0 | Anzahl qualifizierter Neupartner |
|
||||||
|
| qualified_abos | int default 0 | Anzahl qualifizierter Kundenabos |
|
||||||
|
| is_qualified | bool default false | Mindestqualifikation erreicht? |
|
||||||
|
| rank | int nullable | Aktuelle Platzierung |
|
||||||
|
| timestamps | | |
|
||||||
|
| unique(incentive_id, user_id) | | |
|
||||||
|
|
||||||
|
### Tabelle `incentive_points_log` – Detaillierte Punktehistorie
|
||||||
|
|
||||||
|
| Feld | Typ | Beschreibung |
|
||||||
|
|------------------------|------------------|---------------------------------------------|
|
||||||
|
| id | bigint PK | |
|
||||||
|
| participant_id | FK | → incentive_participants |
|
||||||
|
| type | enum | partner / abo |
|
||||||
|
| source_type | string | Polymorphic: User oder UserAbo |
|
||||||
|
| source_id | int | ID des Neupartners oder des Abos |
|
||||||
|
| source_label | string | Name/Bezeichnung für Anzeige |
|
||||||
|
| month | tinyint | Bezugsmonat |
|
||||||
|
| year | smallint | Bezugsjahr |
|
||||||
|
| points_onetime | int default 0 | Einmalpunkte (600 bzw. 400) |
|
||||||
|
| points_accumulated | int default 0 | Akkumulierte Punkte des Monats |
|
||||||
|
| is_storno | bool default false | Storno-Eintrag? |
|
||||||
|
| storno_of_id | FK nullable | Referenz auf stornierten Eintrag |
|
||||||
|
| user_sales_volume_id | FK nullable | Verknüpfung zur Quelle |
|
||||||
|
| timestamps | | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Models
|
||||||
|
|
||||||
|
### `Incentive` (app/Models/Incentive.php)
|
||||||
|
- Casts: status als Enum, dates als Carbon
|
||||||
|
- Relationships: `participants()` hasMany, `activeParticipants()`
|
||||||
|
- Scopes: `scopeActive()`, `scopeInQualificationPeriod()`
|
||||||
|
- Sluggable-Konfiguration für URL-Slug
|
||||||
|
- Hilfsmethoden: `isActive()`, `isInQualificationPeriod()`, `isInCalculationPeriod()`
|
||||||
|
|
||||||
|
### `IncentiveParticipant` (app/Models/IncentiveParticipant.php)
|
||||||
|
- Relationships: `incentive()` belongsTo, `user()` belongsTo, `pointsLog()` hasMany
|
||||||
|
- Scopes: `scopeQualified()`, `scopeWinners()` (qualified + rank <= max_winners)
|
||||||
|
- Methode: `recalculatePoints()`, `checkQualification()`
|
||||||
|
|
||||||
|
### `IncentivePointsLog` (app/Models/IncentivePointsLog.php)
|
||||||
|
- Relationships: `participant()` belongsTo, `salesVolume()` belongsTo
|
||||||
|
- Scopes: `scopePartner()`, `scopeAbo()`, `scopeActive()` (nicht storniert)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Service-Architektur
|
||||||
|
|
||||||
|
### `IncentiveTracker` (app/Services/Incentive/IncentiveTracker.php)
|
||||||
|
|
||||||
|
Wird aus bestehenden Klassen aufgerufen. Enthält statische Methoden:
|
||||||
|
|
||||||
|
#### `trackNewPartner(ShoppingOrder $shopping_order)`
|
||||||
|
- Wird aufgerufen in: `Payment::paymentStatusPaidAction()` bei **`payment_for == 1`** (Wizard-/Registrierungskauf)
|
||||||
|
- **Zusätzliche Bedingung:** `ShoppingOrder::qualifiesForIncentiveTrackedPartner()` — bezahlte Registrierung **und** mindestens eine Bestellposition mit Produkt, das **keine** reine Mitgliedschaft ohne Starterpaket ist (`Product::is_membership_only === false`). Siehe `wherePaidRegistrationIncludesStarterKit()` auf `ShoppingOrder`.
|
||||||
|
- Logik (Kern):
|
||||||
|
1. Neuer User aus `auth_user_id`; Sponsor: `User::m_sponsor`
|
||||||
|
2. Aktive Incentives im Registrierungszeitpunkt (Qualifikationszeitraum)
|
||||||
|
3. Sponsor muss `IncentiveParticipant` haben
|
||||||
|
4. `incentive_new_partners` (Tracking) + `incentive_points_log` (Einmalpunkte) + `recalculateFromTrackingTables()` + Ranking
|
||||||
|
|
||||||
|
#### `trackAboActivated(ShoppingOrder $shopping_order)`
|
||||||
|
- Wird aufgerufen in: `Payment::paymentStatusPaidAction()` (nach `AboHelper::setAboActive`, Zeile 280)
|
||||||
|
- **Wichtig**: Nur wenn `setAboActive` mit `status=2, paid=true` aufgerufen wird
|
||||||
|
- Bedingung: Abo `is_for === 'ot'` (Kundenabo, nicht eigenes Berater-Abo)
|
||||||
|
- Logik:
|
||||||
|
1. Berater ermitteln: `UserAbo->user_id` (= auth_user_id des Sponsors)
|
||||||
|
2. Prüfen ob Berater Teilnehmer eines aktiven Incentives ist
|
||||||
|
3. Prüfen ob Abo-Abschluss im Qualifikationszeitraum liegt
|
||||||
|
4. `incentive_points_log` anlegen: type=abo, source=UserAbo, points_onetime=400
|
||||||
|
5. `incentive_participants.qualified_abos` inkrementieren
|
||||||
|
6. Qualifikation + Ranking aktualisieren
|
||||||
|
|
||||||
|
#### `trackSalesVolume(UserSalesVolume $user_sales_volume)`
|
||||||
|
- Wird aufgerufen in: `InvoiceRepository::createAndSalesVolume()` (nach Erzeugung des `UserSalesVolume`)
|
||||||
|
- Logik:
|
||||||
|
1. **Pfad A:** `user_sales_volume.user_id` ist ein **getrackter Neupartner** (`IncentiveNewPartner`) → akkumulierte Punkte zum Sponsor-Teilnehmer (Log-Typ `partner`)
|
||||||
|
2. **Pfad B:** Bestellung gehört zu einem **getrackten Kundenabo** (`IncentiveNewAbo` über `UserAbo` `is_for = 'ot'`) → akkumulierte Punkte (Log-Typ `abo`)
|
||||||
|
3. Monat/Jahr im Berechnungs-Scope des jeweiligen Incentives (`isDateInScope`)
|
||||||
|
4. `recalculateFromTrackingTables()` + Ranking
|
||||||
|
|
||||||
|
#### `trackStorno(UserSalesVolume $original, UserSalesVolume $cancellation)`
|
||||||
|
- Wird aufgerufen in: `SalesPointsVolume::cancelSalesPointsVolume()` (nach Zeile 312)
|
||||||
|
- Logik:
|
||||||
|
1. Prüfen ob original `user_sales_volume_id` in `incentive_points_log` existiert
|
||||||
|
2. Storno-Eintrag anlegen: `is_storno=true`, negative Punkte, `storno_of_id` verknüpfen
|
||||||
|
3. Gesamtpunkte + Qualifikation + Ranking aktualisieren
|
||||||
|
4. Bei Storno eines Partners/Abos: `qualified_partners` bzw. `qualified_abos` dekrementieren
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `IncentiveCalculationService` (app/Services/Incentive/IncentiveCalculationService.php)
|
||||||
|
|
||||||
|
Batch-Berechnung als Sicherheitsnetz (Cron + manuelle Auslösung):
|
||||||
|
|
||||||
|
#### `recalculate(Incentive $incentive, bool $force = false)`
|
||||||
|
- Iteriert über alle Teilnehmer
|
||||||
|
- Für jeden Teilnehmer:
|
||||||
|
1. Neupartner ermitteln: Users mit `m_sponsor = participant.user_id`, registriert im Qualifikationszeitraum, mit bezahltem Starterpaket
|
||||||
|
2. Kundenabos ermitteln: `UserAbo` mit `user_id = participant.user_id`, `is_for = 'ot'`, `status = 2`, erstellt im Qualifikationszeitraum
|
||||||
|
3. Akkumulierte Punkte: `UserSalesVolume` pro Monat (Qualifikationsstart bis calculation_end)
|
||||||
|
4. Stornos berücksichtigen (status=6 Einträge)
|
||||||
|
5. `incentive_points_log` aktualisieren (bei --force: löschen + neu anlegen)
|
||||||
|
6. `incentive_participants` Summen + Qualifikation aktualisieren
|
||||||
|
|
||||||
|
#### `updateRanking(Incentive $incentive)`
|
||||||
|
- Alle Teilnehmer nach `total_points` DESC sortieren
|
||||||
|
- `rank` fortlaufend vergeben (1, 2, 3, ...)
|
||||||
|
- `is_qualified` prüfen: `qualified_partners >= min_direct_partners AND qualified_abos >= min_customer_abos`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Integrationspunkte (Hooks in bestehenden Code)
|
||||||
|
|
||||||
|
### Hook 1: Neupartner-Registrierung bezahlt
|
||||||
|
**Datei**: `app/Services/Payment.php` → `paymentStatusPaidAction()`
|
||||||
|
**Bedingung**: `payment_for == 1` (entspricht Wizard laut `ShoppingUser::getOrderPaymentFor()`).
|
||||||
|
**Intern:** `trackNewPartner` bricht ab, wenn keine Starterpaket-Position vorliegt (`qualifiesForIncentiveTrackedPartner()`).
|
||||||
|
|
||||||
|
### Hook 2: Kundenabo aktiviert (Zahlung bestätigt)
|
||||||
|
**Datei**: `app/Services/Payment.php` → `paymentStatusPaidAction()`
|
||||||
|
**Position**: Nach Zeile 280 (nach `AboHelper::setAboActive`)
|
||||||
|
**Code**:
|
||||||
|
```php
|
||||||
|
// Incentive: Track activated customer abo
|
||||||
|
if ($shopping_order->is_abo) {
|
||||||
|
IncentiveTracker::trackAboActivated($shopping_order);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hook 3: Sales Volume erstellt
|
||||||
|
**Datei**: `app/Repositories/InvoiceRepository.php` → `createAndSalesVolume()`
|
||||||
|
**Position**: Nach Zeile 223 (nach Verknüpfung Sales Volume ↔ Invoice)
|
||||||
|
**Code**:
|
||||||
|
```php
|
||||||
|
// Incentive: Track sales volume points
|
||||||
|
IncentiveTracker::trackSalesVolume($this->user_sales_volume);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hook 4: Storno
|
||||||
|
**Datei**: `app/Services/BusinessPlan/SalesPointsVolume.php` → `cancelSalesPointsVolume()`
|
||||||
|
**Position**: Nach Zeile 312 (nach Recalculation)
|
||||||
|
**Code**:
|
||||||
|
```php
|
||||||
|
// Incentive: Track storno
|
||||||
|
IncentiveTracker::trackStorno($original_sales_volume, $cancellation_sales_volume);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Admin-Bereich
|
||||||
|
|
||||||
|
### Controller: `app/Http/Controllers/Admin/IncentiveController.php`
|
||||||
|
|
||||||
|
| Route | Methode | Beschreibung |
|
||||||
|
|----------------------------------------|---------------|---------------------------------------|
|
||||||
|
| GET `/admin/incentive` | index | Liste aller Incentives (DataTable) |
|
||||||
|
| GET `/admin/incentive/create` | create | Anlageformular (Spatie\Html) |
|
||||||
|
| POST `/admin/incentive` | store | Speichern |
|
||||||
|
| GET `/admin/incentive/{id}` | show | Detail + Teilnehmer-Ranking |
|
||||||
|
| GET `/admin/incentive/{id}/edit` | edit | Bearbeiten |
|
||||||
|
| PUT `/admin/incentive/{id}` | update | Aktualisieren |
|
||||||
|
| POST `/admin/incentive/{id}/recalculate` | recalculate | Neuberechnung auslösen |
|
||||||
|
|
||||||
|
### Views
|
||||||
|
- `resources/views/admin/incentive/index.blade.php` – DataTable mit allen Incentives
|
||||||
|
- `resources/views/admin/incentive/create.blade.php` – Formular (Spatie\Html)
|
||||||
|
- `resources/views/admin/incentive/edit.blade.php` – Bearbeitungsformular
|
||||||
|
- `resources/views/admin/incentive/show.blade.php` – Detail: Konfiguration + Ranking-Tabelle aller Teilnehmer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Berater-Frontend (User-Bereich)
|
||||||
|
|
||||||
|
### Controller: `app/Http/Controllers/User/IncentiveController.php`
|
||||||
|
|
||||||
|
| Route | Methode | Beschreibung |
|
||||||
|
|--------------------------------------|---------------|-----------------------------------------|
|
||||||
|
| GET `/incentive/{slug}` | show | Incentive-Seite mit Ranking |
|
||||||
|
| POST `/incentive/{slug}/participate` | participate | Teilnahme bestätigen (Terms akzeptieren)|
|
||||||
|
| GET `/incentive/{slug}/details` | details | Persönliche Berechnungsübersicht |
|
||||||
|
|
||||||
|
### View: Incentive-Seite (`user/incentive/show.blade.php`)
|
||||||
|
|
||||||
|
**Layout:**
|
||||||
|
1. **Header**: Statisches Incentive-Bild
|
||||||
|
2. **Beschreibung**: Erklärungstext aus DB
|
||||||
|
3. **Teilnahme-Box** (nur wenn noch nicht teilgenommen):
|
||||||
|
- Checkbox: "Ich akzeptiere die Teilnahmebedingungen" (Link zu Terms)
|
||||||
|
- Button: "Jetzt teilnehmen"
|
||||||
|
4. **Ranking-Tabelle** (nur Teilnehmer sichtbar):
|
||||||
|
|
||||||
|
| Rang | Name | Punkte | Partner | Abos | Status |
|
||||||
|
|------|------------------|--------|---------|-------|------------------|
|
||||||
|
| 1 | **Max Muster** | **4800** | **5/4 ✓** | **8/6 ✓** | **🏆 Gewinner** |
|
||||||
|
| 2 | **Anna Beispiel** | **4200** | **4/4 ✓** | **7/6 ✓** | **🏆 Gewinner** |
|
||||||
|
| ... | ... | ... | ... | ... | ... |
|
||||||
|
| 31 | **Klaus Test** | **1200** | **4/4 ✓** | **6/6 ✓** | Qualifiziert |
|
||||||
|
| 32 | Peter Demo | 900 | 3/4 | 4/6 | Offen |
|
||||||
|
|
||||||
|
**Darstellungslogik:**
|
||||||
|
- **Normal** (nicht fett): Mindestqualifikation NICHT erreicht
|
||||||
|
- **Fett**: Mindestqualifikation erreicht (≥4 Partner + ≥6 Abos)
|
||||||
|
- **Fett + farbig hinterlegt (Gold/Grün)**: Qualifiziert + Rang ≤ max_winners → aktueller Gewinner
|
||||||
|
|
||||||
|
### View: Berechnungsübersicht (`user/incentive/details.blade.php`)
|
||||||
|
|
||||||
|
**Sektion A: Neupartner-Punkte**
|
||||||
|
|
||||||
|
| Neupartner | Einstieg | Einmalig | Apr | Mai | Jun | Jul | Aug | Gesamt |
|
||||||
|
|-----------------|-----------|----------|------|------|------|------|------|--------|
|
||||||
|
| Max Muster | April | 600 | 120 | 150 | 130 | 140 | 110 | 1250 |
|
||||||
|
| Anna Beispiel | Juni | 600 | – | – | 80 | 90 | 100 | 870 |
|
||||||
|
| **Zwischensumme** | | | | | | | | **2120** |
|
||||||
|
|
||||||
|
**Sektion B: Kundenabo-Punkte**
|
||||||
|
|
||||||
|
| Kundenabo | Abschluss | Einmalig | Apr | Mai | Jun | Jul | Aug | Gesamt |
|
||||||
|
|-----------------|-----------|----------|------|------|------|------|------|--------|
|
||||||
|
| Abo #1 (Müller) | April | 400 | 80 | 80 | 80 | 80 | 80 | 800 |
|
||||||
|
| Abo #2 (Schmidt)| Mai | 400 | – | 60 | 60 | 60 | 60 | 640 |
|
||||||
|
| **Zwischensumme** | | | | | | | | **1440** |
|
||||||
|
|
||||||
|
**Gesamtpunkte: 3560**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Artisan Command
|
||||||
|
|
||||||
|
### `php artisan incentive:calculate {incentive_id?} [--force]`
|
||||||
|
|
||||||
|
- Ohne `incentive_id`: Berechnet alle aktiven Incentives
|
||||||
|
- Mit `incentive_id`: Berechnet nur das angegebene Incentive
|
||||||
|
- `--force`: Löscht bestehende Logs und berechnet komplett neu aus Quelldaten
|
||||||
|
- **Cron**: Täglich um 05:00 (nach business:store-optimized um 03:00)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Localization
|
||||||
|
|
||||||
|
`resources/lang/{de,en,es}/incentive.php`
|
||||||
|
|
||||||
|
Enthält alle UI-Strings für Admin- und Berater-Bereich.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Tests (Pest)
|
||||||
|
|
||||||
|
| Test | Typ | Beschreibung |
|
||||||
|
|-------------------------------|---------|--------------------------------------------------------|
|
||||||
|
| Neupartner-Tracking | Feature | Partner registriert + bezahlt → 600 Einmalpunkte |
|
||||||
|
| Abo nur bei Aktivierung | Feature | Abo angelegt aber nicht bezahlt → keine Punkte |
|
||||||
|
| Abo bei Aktivierung | Feature | Abo bezahlt (status=2) → 400 Einmalpunkte |
|
||||||
|
| Nur Kundenabos zählen | Feature | Eigenes Berater-Abo (is_for=me) → keine Punkte |
|
||||||
|
| Akkumulierte Punkte | Feature | Monatliche Sales Volume → korrekt pro Monat summiert |
|
||||||
|
| Storno | Feature | Storno → negative Punkte, Qualifikation angepasst |
|
||||||
|
| Qualifikation erreicht | Unit | 4 Partner + 6 Abos → is_qualified=true |
|
||||||
|
| Qualifikation nicht erreicht | Unit | 3 Partner + 6 Abos → is_qualified=false |
|
||||||
|
| Ranking-Sortierung | Unit | Teilnehmer korrekt nach Punkten sortiert |
|
||||||
|
| Top-N Gewinner | Unit | Nur qualifizierte in Top 30 als Gewinner markiert |
|
||||||
|
| Opt-in erforderlich | Feature | Nicht-Teilnehmer nicht im Ranking |
|
||||||
|
| Terms-Akzeptanz | Feature | Teilnahme ohne Checkbox → Fehler |
|
||||||
|
| Admin CRUD | Feature | Incentive anlegen/bearbeiten/anzeigen |
|
||||||
|
| Zeitraum-Validierung | Unit | Events außerhalb Qualifikationszeitraum → ignoriert |
|
||||||
|
| Batch-Neuberechnung | Feature | Artisan Command berechnet korrekt |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Implementierungs-Reihenfolge
|
||||||
|
|
||||||
|
### Phase 1: Fundament (Tag 1-2) -- ERLEDIGT 17.03.2026
|
||||||
|
- [x] Migrations erstellen (3 Tabellen: incentives, incentive_participants, incentive_points_log)
|
||||||
|
- [x] Models erstellen (Incentive, IncentiveParticipant, IncentivePointsLog)
|
||||||
|
- [x] Migrations ausgeführt
|
||||||
|
- [x] Factories für Tests
|
||||||
|
|
||||||
|
### Phase 2: Kern-Logik (Tag 3-5) -- ERLEDIGT 17.03.2026
|
||||||
|
- [x] `IncentiveTracker` Service implementieren (`app/Services/Incentive/IncentiveTracker.php`)
|
||||||
|
- trackNewPartner: Hook nach Registration-Payment (payment_for=1)
|
||||||
|
- trackAboActivated: Hook nach AboHelper::setAboActive (is_for='ot')
|
||||||
|
- trackSalesVolume: Hook nach InvoiceRepository::createAndSalesVolume
|
||||||
|
- trackStorno: Hook nach SalesPointsVolume::cancelSalesPointsVolume
|
||||||
|
- updateRanking: Ranking-Aktualisierung
|
||||||
|
- [x] `IncentiveCalculationService` implementieren (`app/Services/Incentive/IncentiveCalculationService.php`)
|
||||||
|
- recalculate: Batch-Berechnung aller Teilnehmer via `recalculateFromSource()`
|
||||||
|
- recalculateParticipant: Delegiert an `IncentiveParticipant::recalculateFromSource()`
|
||||||
|
- [x] Hooks in bestehende Klassen eingebaut:
|
||||||
|
- `app/Services/Payment.php`: trackNewPartner + trackAboActivated
|
||||||
|
- `app/Repositories/InvoiceRepository.php`: trackSalesVolume
|
||||||
|
- `app/Services/BusinessPlan/SalesPointsVolume.php`: trackStorno
|
||||||
|
- [x] Artisan Command `incentive:calculate` (`app/Console/Commands/IncentiveCalculate.php`)
|
||||||
|
- [x] Cron-Eintrag in `app/Console/Kernel.php` (täglich 05:00)
|
||||||
|
|
||||||
|
### Phase 3: Admin-Backend (Tag 6-7) -- ERLEDIGT 17.03.2026
|
||||||
|
- [x] Admin Controller (`app/Http/Controllers/Admin/IncentiveController.php`)
|
||||||
|
- CRUD: index, create, store, show, edit, update
|
||||||
|
- DataTable für Listenansicht
|
||||||
|
- Neuberechnung (normal + force)
|
||||||
|
- [x] Admin Views:
|
||||||
|
- `resources/views/admin/incentive/index.blade.php` (DataTable)
|
||||||
|
- `resources/views/admin/incentive/create.blade.php`
|
||||||
|
- `resources/views/admin/incentive/edit.blade.php`
|
||||||
|
- `resources/views/admin/incentive/_form.blade.php` (Shared Formular)
|
||||||
|
- `resources/views/admin/incentive/show.blade.php` (Detail + Ranking)
|
||||||
|
- [x] Admin Routes in `routes/domains/crm.php` (admin_incentive_*)
|
||||||
|
|
||||||
|
### Phase 4: Berater-Frontend (Tag 8-9) -- ERLEDIGT 17.03.2026
|
||||||
|
- [x] User Controller (`app/Http/Controllers/User/IncentiveController.php`)
|
||||||
|
- show: Incentive-Seite mit Ranking
|
||||||
|
- participate: Teilnahme mit Terms-Akzeptanz
|
||||||
|
- details: Persönliche Berechnungsübersicht
|
||||||
|
- [x] Ranking-View mit Qualifikations-Hervorhebung (`resources/views/user/incentive/show.blade.php`)
|
||||||
|
- Normal = nicht qualifiziert, Fett = qualifiziert, Grün+Fett = Gewinner (Top N)
|
||||||
|
- Eigener Rang hervorgehoben (table-primary)
|
||||||
|
- [x] Berechnungsübersicht-View (`resources/views/user/incentive/details.blade.php`)
|
||||||
|
- Sektion A: Neupartner mit monatlicher Aufschlüsselung
|
||||||
|
- Sektion B: Kundenabos mit monatlicher Aufschlüsselung
|
||||||
|
- Zusammenfassung: Gesamtpunkte, Rang, Qualifikationsstatus
|
||||||
|
- [x] Teilnahme-Flow (Checkbox + Terms mit Collapse)
|
||||||
|
- [x] User Routes in `routes/domains/crm.php` (user_incentive_*)
|
||||||
|
|
||||||
|
### Phase 5: Abschluss (Tag 10) -- ERLEDIGT 17.03.2026
|
||||||
|
- [x] Localization (de/en/es) in `resources/lang/{de,en,es}/incentive.php`
|
||||||
|
- [x] `./vendor/bin/pint` Code formatiert
|
||||||
|
- [x] Architektur-Refactoring: Eigene Tracking-Tabellen statt Live-Abfragen auf Quelltabellen
|
||||||
|
- Neue Tabellen: `incentive_new_partners`, `incentive_new_abos`
|
||||||
|
- Neue Models: `IncentiveNewPartner`, `IncentiveNewAbo`
|
||||||
|
- `IncentiveParticipant::recalculateFromTrackingTables()`: Berechnet aus eigenen Tabellen
|
||||||
|
- `IncentiveParticipant::rebuildFromSourceTables()`: Force-Rebuild aus Quelltabellen
|
||||||
|
- `IncentiveTracker`: Insert in Tracking-Tabellen + Log + `recalculateFromTrackingTables()`
|
||||||
|
- `IncentiveCalculationService`: Normal=TrackingTables, Force=RebuildFromSource
|
||||||
|
- Details-View: Partner/Abos aus Tracking-Tabellen statt aus Log gruppiert
|
||||||
|
- [x] Tests geschrieben + ausgeführt: **27 Tests, 55 Assertions, alle bestanden**
|
||||||
|
- `tests/Unit/Incentive/IncentiveModelTest.php` (19 Tests)
|
||||||
|
- Zeitraum-Prüfungen (Qualifikation, Berechnung, Scope)
|
||||||
|
- Status-Erkennung (draft/active/closed)
|
||||||
|
- Qualifikations-Logik (bestanden/nicht bestanden, Grenzwerte)
|
||||||
|
- Winner-Logik (Rang-Grenzen, Nicht-Qualifiziert)
|
||||||
|
- PointsLog Helpers
|
||||||
|
- `tests/Unit/Incentive/IncentiveTrackerTest.php` (8 Tests)
|
||||||
|
- Ranking-Sortierung nach Punkten
|
||||||
|
- Log-Typ-Erkennung (partner/abo)
|
||||||
|
- Qualifikations-Änderungen bei Storno
|
||||||
|
- Edge Cases (max_winners=1, negative Punkte)
|
||||||
|
- Factories: `IncentiveFactory`, `IncentiveParticipantFactory`, `IncentivePointsLogFactory`
|
||||||
|
- Hinweis: Unit-Tests ohne DB (bestehende countries-Migration hat SQLite-Inkompatibilität)
|
||||||
|
- [ ] Manueller Test mit Testdaten
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Verfeinerungen nach Erstrelease (März 2026)
|
||||||
|
|
||||||
|
### Ranglisten & Darstellung (User + Admin)
|
||||||
|
|
||||||
|
- **`orderByIncentiveLeaderboard()`** (`IncentiveParticipant`): Sortierung: `is_qualified` absteigend → Rang mit Wert vor `NULL` → `rank` aufsteigend → `total_points` absteigend (qualifizierte zuerst; ohne Rang unten).
|
||||||
|
- **`orderByRankNullsLast()`**: nur Rang-Sortierung (NULL zuletzt), falls separat benötigt.
|
||||||
|
- **User-Rangliste:** `limit` = `max(1, incentive.max_winners)` — dynamisch statt fest 30.
|
||||||
|
- **`withRankingActivity()`:** In der User-Tabelle werden nur Teilnehmer mit mindestens einem qualifizierten Partner, einem Kunden-Abo oder `total_points > 0` gelistet.
|
||||||
|
|
||||||
|
### Neupartner nur mit Starterpaket
|
||||||
|
|
||||||
|
- **`ShoppingOrder::wherePaidRegistrationIncludesStarterKit()`** / **`qualifiesForIncentiveTrackedPartner()`**: Registrierungsbestellung (`payment_for = 1`) mit bezahlter Zahlung **und** mindestens einer Position, deren Produkt **`is_membership_only = false`**.
|
||||||
|
- Verwendung in: `IncentiveTracker::trackNewPartner`, `IncentiveParticipant::rebuildFromSourceTables` (Block Neupartner), `IncentivePointsLogRepairService::syncMissingTrackingPartners`.
|
||||||
|
|
||||||
|
### Tests (Auswahl)
|
||||||
|
|
||||||
|
- `tests/Feature/Incentive/IncentiveParticipantRankOrderingTest.php` — Sortierung, Limit, Aktivitätsfilter.
|
||||||
|
- `tests/Feature/Incentive/IncentivePartnerRegistrationStarterKitTest.php` — Tracking nur mit Starterpaket-Position.
|
||||||
|
|
||||||
|
### Dokumentation
|
||||||
|
|
||||||
|
- **`dev/Incentive-Modul/README.md`** — Modulübersicht für Entwickler/PM.
|
||||||
|
- **`dev/Incentive-Modul/tasks.md`** — Anforderungen und Status.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Datei-Übersicht (erstellt)
|
||||||
|
|
||||||
|
```
|
||||||
|
database/migrations/
|
||||||
|
├── 2026_03_17_000001_create_incentives_table.php ✅
|
||||||
|
├── 2026_03_17_000002_create_incentive_participants_table.php ✅
|
||||||
|
├── 2026_03_17_000003_create_incentive_points_log_table.php ✅
|
||||||
|
├── 2026_03_17_000004_create_incentive_new_partners_table.php ✅
|
||||||
|
└── 2026_03_17_000005_create_incentive_new_abos_table.php ✅
|
||||||
|
|
||||||
|
database/factories/
|
||||||
|
├── IncentiveFactory.php ✅
|
||||||
|
├── IncentiveParticipantFactory.php ✅
|
||||||
|
├── IncentivePointsLogFactory.php ✅
|
||||||
|
├── IncentiveNewPartnerFactory.php ✅
|
||||||
|
└── IncentiveNewAboFactory.php ✅
|
||||||
|
|
||||||
|
app/Models/
|
||||||
|
├── Incentive.php ✅
|
||||||
|
├── IncentiveParticipant.php ✅
|
||||||
|
├── IncentivePointsLog.php ✅
|
||||||
|
├── IncentiveNewPartner.php ✅
|
||||||
|
└── IncentiveNewAbo.php ✅
|
||||||
|
|
||||||
|
app/Services/Incentive/
|
||||||
|
├── IncentiveTracker.php ✅
|
||||||
|
└── IncentiveCalculationService.php ✅
|
||||||
|
|
||||||
|
app/Http/Controllers/Admin/
|
||||||
|
└── IncentiveController.php ✅
|
||||||
|
|
||||||
|
app/Http/Controllers/User/
|
||||||
|
└── IncentiveController.php ✅
|
||||||
|
|
||||||
|
app/Console/Commands/
|
||||||
|
└── IncentiveCalculate.php ✅
|
||||||
|
|
||||||
|
resources/views/admin/incentive/
|
||||||
|
├── index.blade.php ✅
|
||||||
|
├── create.blade.php ✅
|
||||||
|
├── edit.blade.php ✅
|
||||||
|
├── _form.blade.php ✅
|
||||||
|
└── show.blade.php ✅
|
||||||
|
|
||||||
|
resources/views/user/incentive/
|
||||||
|
├── show.blade.php ✅
|
||||||
|
└── details.blade.php ✅
|
||||||
|
|
||||||
|
resources/lang/de/incentive.php ✅
|
||||||
|
resources/lang/en/incentive.php ✅
|
||||||
|
resources/lang/es/incentive.php ✅
|
||||||
|
|
||||||
|
tests/Pest.php ✅
|
||||||
|
tests/Unit/Incentive/
|
||||||
|
├── IncentiveModelTest.php ✅ (19 Tests)
|
||||||
|
└── IncentiveTrackerTest.php ✅ (8 Tests)
|
||||||
|
|
||||||
|
tests/Feature/Incentive/
|
||||||
|
├── AboRenewalSalesVolumeIncentiveTest.php ✅
|
||||||
|
├── IncentiveParticipantRankOrderingTest.php ✅
|
||||||
|
└── IncentivePartnerRegistrationStarterKitTest.php ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bestehende Dateien mit Änderungen (Hooks)
|
||||||
|
|
||||||
|
| Datei | Änderung |
|
||||||
|
|------------------------------------------------|---------------------------------------------|
|
||||||
|
| `app/Services/Payment.php` | 2 Hooks: trackNewPartner (`payment_for==1`), trackAboActivated |
|
||||||
|
| `app/Models/ShoppingOrder.php` | Scopes/Methoden: Starterpaket-Registrierung (`wherePaidRegistrationIncludesStarterKit`, `qualifiesForIncentiveTrackedPartner`) |
|
||||||
|
| `app/Repositories/InvoiceRepository.php` | 1 Hook: trackSalesVolume |
|
||||||
|
| `app/Services/BusinessPlan/SalesPointsVolume.php` | 1 Hook: trackStorno |
|
||||||
|
| `routes/domains/crm.php` | Admin + User Routes hinzufügen |
|
||||||
|
| `app/Console/Kernel.php` | Cron-Eintrag für incentive:calculate |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architektur-Änderung: Eigene Tracking-Tabellen (17.03.2026)
|
||||||
|
|
||||||
|
**Entscheidung**: Statt bei jedem Event die Quelltabellen (Users, UserAbos, UserSalesVolumes) abzufragen, werden **eigene Tracking-Tabellen** geführt.
|
||||||
|
|
||||||
|
**Grund**: Performance-Optimierung + saubere Nachverfolgbarkeit, welche Partner/Abos einem Teilnehmer zugeordnet sind.
|
||||||
|
|
||||||
|
### Neue Tabellen:
|
||||||
|
- `incentive_new_partners` (participant_id, user_id, registered_at) - Welche Neupartner gehören zu welchem Teilnehmer
|
||||||
|
- `incentive_new_abos` (participant_id, user_abo_id, activated_at) - Welche Kundenabos gehören zu welchem Teilnehmer
|
||||||
|
|
||||||
|
### Berechnungslogik:
|
||||||
|
|
||||||
|
**Normal (bei Events + Cron)** via `recalculateFromTrackingTables()`:
|
||||||
|
```
|
||||||
|
qualified_partners = COUNT(incentive_new_partners)
|
||||||
|
qualified_abos = COUNT(incentive_new_abos)
|
||||||
|
total_points = SUM(points_onetime + points_accumulated) aus incentive_points_log
|
||||||
|
```
|
||||||
|
|
||||||
|
**Force-Rebuild** via `rebuildFromSourceTables()`:
|
||||||
|
1. Leert Tracking-Tabellen + Log
|
||||||
|
2. Scannt Quelltabellen (Users, UserAbos, UserSalesVolumes)
|
||||||
|
3. **Neupartner (Block A):** nur User mit bezahlter Registrierung, die **`wherePaidRegistrationIncludesStarterKit()`** erfüllt (Starterpaket-Regel wie im Live-Tracking)
|
||||||
|
4. Füllt `incentive_new_partners`, `incentive_new_abos`, `incentive_points_log` neu
|
||||||
|
5. Berechnet Totals via `recalculateFromTrackingTables()`
|
||||||
|
|
||||||
|
### Datenfluss bei Events:
|
||||||
|
1. **Neupartner**: → INSERT `incentive_new_partners` + Log + Recalculate
|
||||||
|
2. **Abo aktiviert**: → INSERT `incentive_new_abos` + Log + Recalculate
|
||||||
|
3. **Sales Volume**: → INSERT Log (akkumulierte Punkte) + Recalculate
|
||||||
|
4. **Storno**: → INSERT negativer Log-Eintrag + Recalculate
|
||||||
97
dev/Incentive-Modul/site.md
Normal file
97
dev/Incentive-Modul/site.md
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
> **Dokumentation zum Incentive-Modul (Technik):** siehe [`README.md`](README.md) und [`entwicklungsplan.md`](entwicklungsplan.md).
|
||||||
|
> **Aufgaben/Status:** [`tasks.md`](tasks.md).
|
||||||
|
> Die folgende Datei enthält **Content-Texte** (Berater-Oberfläche / Montenegro 2026), keine technische Spezifikation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Das ist eine sehr gute Entscheidung, bei dem Begriff zu bleiben! Für das Frontend im CRM brauchen wir jetzt einen Text, der professionell, motivierend und auf den ersten Blick verständlich ist. Schließlich sollen die Berater direkt Lust bekommen, loszulegen.
|
||||||
|
|
||||||
|
Hier ist ein Struktur- und Textentwurf für die Inhaltsseite im System.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [Platzhalter: Großes, ansprechendes Titelbild von einem Luxushotel oder der Küste Montenegros einfügen]
|
||||||
|
|
||||||
|
# (icon) Montenegro Incentive 2026 – Deine exklusive Auszeit an der Adria!
|
||||||
|
|
||||||
|
**Pack deine Koffer, denn mivita belohnt deine Bestleistungen!** Vom **30. September bis zum 4. Oktober 2026** geht es für unsere erfolgreichsten Partner an die malerische Küste Montenegros. Erlebe unvergessliche Tage, tausche dich mit den Top-Leadern aus und feiere deinen Erfolg mit uns!
|
||||||
|
|
||||||
|
Gehörst du zu den **besten 30 Partnern**? Dann bist du dabei!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### (icon) Der Qualifikationszeitraum
|
||||||
|
Der Startschuss für die Qualifikation fällt am **1. April**.
|
||||||
|
* **Regulärer Zeitraum:** April bis Juli
|
||||||
|
* **Endspurt:** Der Zeitraum wird um 1 Monat verlängert (Punkte zählen bis einschließlich August ).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### (icon) Dein Ticket in den Flieger: Die Mindestqualifikation
|
||||||
|
Um dir deinen Platz im Ranking freizuschalten und überhaupt für die Reise in Frage zu kommen, musst du im Qualifikationszeitraum folgende Basis-Ziele erreichen:
|
||||||
|
* **4 direkte neue Teampartner** (jeweils mit einem Starterpaket)
|
||||||
|
* **6 neu abgeschlossene Kundenabos**
|
||||||
|
|
||||||
|
*(Hinweis: Im Live-Ranking unten wird dein Name erst dann **fettgedruckt** hervorgehoben, wenn du diese Mindestqualifikation erfolgreich geknackt hast!)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### (icon) So sammelst du deine Incentive-Punkte
|
||||||
|
Sobald der Startschuss fällt, zählt jeder Umsatz. Deine Punkte setzen sich aus Einmal-Boni und laufenden Umsätzen zusammen:
|
||||||
|
|
||||||
|
**1. Punkte für neue Teampartner**
|
||||||
|
* **600 Punkte einmalig** für jeden direkt gesponserten Neupartner.
|
||||||
|
* **Zusatz-Boost:** Du erhältst zusätzlich *alle* Kundenumsatzpunkte deines Neupartners ab seinem Startmonat bis einschließlich August!
|
||||||
|
|
||||||
|
**2. Punkte für Kundenabos**
|
||||||
|
* **400 Punkte einmalig** pro neu abgeschlossenem Kundenabo im Qualifikationszeitraum.
|
||||||
|
* **Zusatz-Boost:** Du erhältst zusätzlich die jeweiligen monatlichen Abopunkte ab dem Abschlussmonat bis einschließlich August!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### (icon) Das Live-Ranking
|
||||||
|
Deinen aktuellen Punktestand und deine Platzierung im Vergleich zu den anderen Beratern findest du jederzeit in unserer separaten Rangliste.
|
||||||
|
Nur die besten 30 Berater, die auch die Mindestqualifikation erfüllen, sichern sich das Ticket nach Montenegro!
|
||||||
|
[Hier geht es direkt zur Live-Rangliste] (Link zur neuen Ranking-Route)
|
||||||
|
|
||||||
|
### (icon) Deine Teilnahme am Montenegro Incentive 2026
|
||||||
|
Bist du bereit für die Challenge? Um am Incentive teilzunehmen und im offiziellen Ranking gelistet zu werden, musst du dich einmalig anmelden.
|
||||||
|
|
||||||
|
[ ] Ich habe die Nutzungsbedingungen gelesen und akzeptiere diese. Ich möchte am Montenegro Incentive 2026 teilnehmen und stimme zu, dass mein Name und mein Punktestand im internen Ranking für andere Berater sichtbar sind.
|
||||||
|
|
||||||
|
[ BUTTON: Jetzt verbindlich teilnehmen ]
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### (icon) Die Nutzungsbedingungen (Teilnahmebedingungen)
|
||||||
|
|
||||||
|
Nutzungsbedingungen (Teilnahmebedingungen)
|
||||||
|
|
||||||
|
Diese Bedingungen sichern euch rechtlich ab und fassen die Regeln aus deinem Briefing für die Berater transparent zusammen.
|
||||||
|
|
||||||
|
**Nutzungs- und Teilnahmebedingungen: Montenegro Incentive 2026**
|
||||||
|
|
||||||
|
**1. Gegenstand des Incentives**
|
||||||
|
Die mivita veranstaltet das „Montenegro Incentive 2026“. Die erfolgreichsten Berater qualifizieren sich für eine exklusive Reise nach Montenegro vom 30. September bis zum 4. Oktober 2026.
|
||||||
|
|
||||||
|
**2. Qualifikationszeitraum**
|
||||||
|
Der reguläre Qualifikationszeitraum startet am 1. April und läuft bis Ende Juli. Für die finale Punkteberechnung (laufende Kunden- und Aboumsätze) wird der Zeitraum um einen weiteren Monat bis einschließlich August verlängert.
|
||||||
|
|
||||||
|
**3. Teilnahmevoraussetzung (Mindestqualifikation)**
|
||||||
|
Um für die Incentive-Reise infrage zu kommen, muss im Qualifikationszeitraum zwingend folgende Mindestqualifikation erreicht werden:
|
||||||
|
* Sponsoring von mindestens **4 direkten Teampartnern** (jeweils mit einem Starterpaket).
|
||||||
|
* Abschluss von mindestens **6 neuen Kundenabos**.
|
||||||
|
|
||||||
|
**4. Punkteberechnung**
|
||||||
|
Teilnehmer sammeln tagesaktuell Punkte, die im Ranking sichtbar sind. Die Punkte setzen sich wie folgt zusammen:
|
||||||
|
* **Neupartner:** Pro direkt gesponsertem Neupartner im Qualifikationszeitraum gibt es einmalig 600 Punkte. Zusätzlich werden alle Kundenumsatzpunkte dieses Neupartners ab seinem Startmonat bis einschließlich August akkumuliert.
|
||||||
|
* **Kundenabos:** Pro neu abgeschlossenem Kundenabo im Qualifikationszeitraum gibt es einmalig 400 Punkte. Zusätzlich werden die monatlichen Abopunkte ab Abschluss bis einschließlich August akkumuliert.
|
||||||
|
|
||||||
|
**5. Gewinnerermittlung und Ranking**
|
||||||
|
Die gesammelten Punkte werden in einer Live-Rangliste dargestellt. Die Darstellung des Namens und der Punkte eines Teilnehmers erfolgt erst dann in **Fettdruck**, wenn die Mindestqualifikation (siehe Punkt 3) vollständig erfüllt ist.
|
||||||
|
Qualifiziert für die Reise sind die **Top 30 Teilnehmer** der Rangliste, die zum Stichtag die Mindestqualifikation erfüllt haben.
|
||||||
|
|
||||||
|
**6. Datenschutz und Sichtbarkeit**
|
||||||
|
Mit der Teilnahme am Incentive stimmt der Berater zu, dass sein Name, seine erreichten Qualifikationen (Anzahl Teampartner/Abos) sowie der Gesamtpunktestand im internen mivita-Ranking für andere Systemnutzer sichtbar sind.
|
||||||
49
dev/Incentive-Modul/tasks.md
Normal file
49
dev/Incentive-Modul/tasks.md
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
# Incentive-Modul – Anforderungen & Aufgaben
|
||||||
|
|
||||||
|
Auszug aus dem Kundenbriefing (`dev/2026-03-17/Incentive-montenegro.pdf`) und Nachverfolgung der Umsetzung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Produktziele
|
||||||
|
|
||||||
|
| # | Anforderung | Status |
|
||||||
|
|---|-------------|--------|
|
||||||
|
| P1 | Eigenes Punkte-/Ranking-System, **losgelöst** von anderen MLM-Berechnungen | Erledigt |
|
||||||
|
| P2 | **Wiederverwendbar**: mehrere Incentives (z. B. jährlich wechselnd) anlegbar | Erledigt |
|
||||||
|
| P3 | Parameter pro Incentive: Zeiträume, Punkte, Mindest-Partner/Abos, **Anzahl Gewinner** (`max_winners`) | Erledigt |
|
||||||
|
| P4 | UI: Inhaltsseite mit Bild, Erklärung, **Rangliste**; Qualifikation und Gewinner **visuell** hervorheben | Erledigt |
|
||||||
|
| P5 | **Detailübersicht** für Berater: Neupartner- und Abo-Berechnung, nach Monaten, nachvollziehbar | Erledigt |
|
||||||
|
| P6 | Neupartner zählen nur mit **Starterpaket** (nicht nur Mitgliedschaft) | Erledigt |
|
||||||
|
| P7 | Rangliste: Qualifizierte oben; sinnvolle Sortierung Rang/Null-Rang; User-Liste begrenzt auf `max_winners`; optionale Filter „ohne Aktivität“ | Erledigt |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technische Arbeitspakete (Referenz)
|
||||||
|
|
||||||
|
| Paket | Beschreibung | Status |
|
||||||
|
|-------|--------------|--------|
|
||||||
|
| T1 | Migrationen `incentives`, `incentive_participants`, `incentive_points_log`, Tracking-Tabellen | Erledigt |
|
||||||
|
| T2 | Models, Factories, `IncentiveTracker`, Hooks in `Payment`, `InvoiceRepository`, `SalesPointsVolume` | Erledigt |
|
||||||
|
| T3 | Admin CRUD + DataTable + Views | Erledigt |
|
||||||
|
| T4 | User-Routen: Show, Participate, Details, Teaser | Erledigt |
|
||||||
|
| T5 | Artisan `incentive:calculate`, Cron | Erledigt |
|
||||||
|
| T6 | Übersetzungen `de` / `en` / `es` | Erledigt |
|
||||||
|
| T7 | Tests (Pest): Model, Tracker, Feature (Ranking, Starterkit, Abo, …) | Laufend erweitert |
|
||||||
|
| T8 | `IncentivePointsLogRepairService`, Debug-Commands (optional) | Vorhanden |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Offen / manuell
|
||||||
|
|
||||||
|
| Aufgabe | Notiz |
|
||||||
|
|---------|--------|
|
||||||
|
| **Manueller End-to-End-Test** | Mit realitätsnahen Testdaten (Zahlung, Registrierung, Abo, Shop) im Staging prüfen |
|
||||||
|
| **Content-Pflege** | `site.md` – Bilder, finale Texte, Links zur Live-Route je nach Deployment |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verwandte Dateien
|
||||||
|
|
||||||
|
- Technischer Fahrplan: **`entwicklungsplan.md`**
|
||||||
|
- Modul-Kurzüberblick: **`README.md`**
|
||||||
|
- Marketing-/Nutzertexte: **`site.md`**
|
||||||
|
|
@ -2,7 +2,7 @@ services:
|
||||||
laravel.test:
|
laravel.test:
|
||||||
container_name: mivita-care-dev-container
|
container_name: mivita-care-dev-container
|
||||||
build:
|
build:
|
||||||
context: './docker/8.4'
|
context: './vendor/laravel/sail/runtimes/8.4'
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
WWWGROUP: '${WWWGROUP:-20}'
|
WWWGROUP: '${WWWGROUP:-20}'
|
||||||
|
|
@ -11,7 +11,6 @@ services:
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- 'host.docker.internal:host-gateway'
|
- 'host.docker.internal:host-gateway'
|
||||||
ports:
|
ports:
|
||||||
# - '${APP_PORT:-80}:80'
|
|
||||||
- '${VITE_PORT:-5173}:${VITE_PORT:-5173}'
|
- '${VITE_PORT:-5173}:${VITE_PORT:-5173}'
|
||||||
environment:
|
environment:
|
||||||
WWWUSER: '${WWWUSER:-501}'
|
WWWUSER: '${WWWUSER:-501}'
|
||||||
|
|
@ -20,26 +19,22 @@ services:
|
||||||
XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}'
|
XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}'
|
||||||
XDEBUG_CONFIG: '${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}'
|
XDEBUG_CONFIG: '${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}'
|
||||||
IGNITION_LOCAL_SITES_PATH: '${PWD}'
|
IGNITION_LOCAL_SITES_PATH: '${PWD}'
|
||||||
# Umgebungsvariablen für Datenbank, Mail etc.
|
# --- Anbindung an das Mutterschiff ---
|
||||||
DB_CONNECTION: mysql
|
DB_CONNECTION: mysql
|
||||||
DB_HOST: mysql
|
DB_HOST: global-mysql
|
||||||
DB_PORT: 3306
|
DB_PORT: 3306
|
||||||
DB_DATABASE: mivita
|
DB_DATABASE: mivita
|
||||||
DB_USERNAME: sail
|
DB_USERNAME: root # Wir nutzen den Root-User des Mutterschiffs
|
||||||
DB_PASSWORD: password
|
DB_PASSWORD: password
|
||||||
MAIL_HOST: mailpit
|
MAIL_HOST: global-mailpit
|
||||||
MAIL_PORT: 1025
|
MAIL_PORT: 1025
|
||||||
REDIS_HOST: redis
|
REDIS_HOST: global-redis
|
||||||
REDIS_PORT: 6379
|
REDIS_PORT: 6379
|
||||||
volumes:
|
volumes:
|
||||||
- '.:/var/www/html'
|
- '.:/var/www/html'
|
||||||
networks:
|
networks:
|
||||||
- sail
|
- sail
|
||||||
- proxy
|
- proxy # WICHTIG für Traefik und Global Services
|
||||||
depends_on:
|
|
||||||
- mysql
|
|
||||||
- redis
|
|
||||||
- mailpit
|
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
# Hauptdomain
|
# Hauptdomain
|
||||||
|
|
@ -47,15 +42,16 @@ services:
|
||||||
- "traefik.http.routers.mivita.entrypoints=websecure"
|
- "traefik.http.routers.mivita.entrypoints=websecure"
|
||||||
- "traefik.http.routers.mivita.tls=true"
|
- "traefik.http.routers.mivita.tls=true"
|
||||||
- "traefik.http.routers.mivita.service=mivita-service"
|
- "traefik.http.routers.mivita.service=mivita-service"
|
||||||
# Wildcard für alle Subdomains - WICHTIG: Gleicher Service!
|
# Wildcard für alle Subdomains
|
||||||
- "traefik.http.routers.mivita-sub.rule=HostRegexp(`^.+\\.mivita\\.test$`)"
|
- "traefik.http.routers.mivita-sub.rule=HostRegexp(`^.+\\.mivita\\.test$`)"
|
||||||
- "traefik.http.routers.mivita-sub.entrypoints=websecure"
|
- "traefik.http.routers.mivita-sub.entrypoints=websecure"
|
||||||
- "traefik.http.routers.mivita-sub.tls=true"
|
- "traefik.http.routers.mivita-sub.tls=true"
|
||||||
- "traefik.http.routers.mivita-sub.service=mivita-service"
|
- "traefik.http.routers.mivita-sub.service=mivita-service"
|
||||||
- "traefik.http.routers.mivita-sub.priority=10"
|
- "traefik.http.routers.mivita-sub.priority=10"
|
||||||
# Service Definition - NUR EINMAL!
|
# Service Definition
|
||||||
- "traefik.http.services.mivita-service.loadbalancer.server.port=80"
|
- "traefik.http.services.mivita-service.loadbalancer.server.port=80"
|
||||||
- "traefik.docker.network=proxy"
|
- "traefik.docker.network=proxy"
|
||||||
|
|
||||||
horizon:
|
horizon:
|
||||||
image: sail-8.4/app
|
image: sail-8.4/app
|
||||||
container_name: mivita-horizon-1
|
container_name: mivita-horizon-1
|
||||||
|
|
@ -65,71 +61,10 @@ services:
|
||||||
- '.:/var/www/html'
|
- '.:/var/www/html'
|
||||||
networks:
|
networks:
|
||||||
- sail
|
- sail
|
||||||
depends_on:
|
- proxy # WICHTIG: Damit Horizon auf global-redis und global-mysql zugreifen kann!
|
||||||
- mysql
|
|
||||||
- redis
|
|
||||||
mysql:
|
|
||||||
image: 'mysql/mysql-server:8.0'
|
|
||||||
ports:
|
|
||||||
- '${FORWARD_DB_PORT:-33061}:3306'
|
|
||||||
environment:
|
|
||||||
MYSQL_ROOT_PASSWORD: '${DB_PASSWORD}'
|
|
||||||
MYSQL_ROOT_HOST: '%'
|
|
||||||
MYSQL_DATABASE: '${DB_DATABASE}'
|
|
||||||
MYSQL_USER: '${DB_USERNAME}'
|
|
||||||
MYSQL_PASSWORD: '${DB_PASSWORD}'
|
|
||||||
MYSQL_ALLOW_EMPTY_PASSWORD: 1
|
|
||||||
MYSQL_EXTRA_OPTIONS: '${MYSQL_EXTRA_OPTIONS}'
|
|
||||||
volumes:
|
|
||||||
- 'sail-mysql:/var/lib/mysql'
|
|
||||||
- './docker/mysql/create-testing-database.sh:/docker-entrypoint-initdb.d/10-create-testing-database.sh'
|
|
||||||
networks:
|
|
||||||
- sail
|
|
||||||
healthcheck:
|
|
||||||
test:
|
|
||||||
- CMD
|
|
||||||
- mysqladmin
|
|
||||||
- ping
|
|
||||||
- '-p${DB_PASSWORD}'
|
|
||||||
retries: 3
|
|
||||||
timeout: 5s
|
|
||||||
redis:
|
|
||||||
image: 'redis:alpine'
|
|
||||||
ports:
|
|
||||||
- '${FORWARD_REDIS_PORT:-6380}:6379'
|
|
||||||
volumes:
|
|
||||||
- 'sail-redis:/data'
|
|
||||||
networks:
|
|
||||||
- sail
|
|
||||||
healthcheck:
|
|
||||||
test:
|
|
||||||
- CMD
|
|
||||||
- redis-cli
|
|
||||||
- ping
|
|
||||||
retries: 3
|
|
||||||
timeout: 5s
|
|
||||||
mailpit:
|
|
||||||
image: 'axllent/mailpit:latest'
|
|
||||||
ports:
|
|
||||||
- '${FORWARD_MAILPIT_PORT:-1025}:1025'
|
|
||||||
- '${FORWARD_MAILPIT_DASHBOARD_PORT:-8025}:8025'
|
|
||||||
networks:
|
|
||||||
- sail
|
|
||||||
- proxy
|
|
||||||
labels:
|
|
||||||
- "traefik.enable=true"
|
|
||||||
- "traefik.http.routers.mivita-mail.rule=Host(`mivita-mail.test`)"
|
|
||||||
- "traefik.http.routers.mivita-mail.entrypoints=websecure"
|
|
||||||
- "traefik.http.routers.mivita-mail.tls=true"
|
|
||||||
- "traefik.http.services.mivita-mail.loadbalancer.server.port=8025"
|
|
||||||
- "traefik.docker.network=proxy"
|
|
||||||
networks:
|
networks:
|
||||||
sail:
|
sail:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
proxy:
|
proxy:
|
||||||
external: true
|
external: true
|
||||||
volumes:
|
|
||||||
sail-mysql:
|
|
||||||
driver: local
|
|
||||||
sail-redis:
|
|
||||||
driver: local
|
|
||||||
|
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
FROM ubuntu:24.04
|
|
||||||
|
|
||||||
LABEL maintainer="Taylor Otwell"
|
|
||||||
|
|
||||||
ARG WWWGROUP
|
|
||||||
ARG NODE_VERSION=22
|
|
||||||
ARG POSTGRES_VERSION=17
|
|
||||||
|
|
||||||
WORKDIR /var/www/html
|
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
|
||||||
ENV TZ=UTC
|
|
||||||
ENV SUPERVISOR_PHP_COMMAND="/usr/bin/php -d variables_order=EGPCS /var/www/html/artisan serve --host=0.0.0.0 --port=80"
|
|
||||||
ENV SUPERVISOR_PHP_USER="sail"
|
|
||||||
|
|
||||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
|
||||||
|
|
||||||
RUN echo "Acquire::http::Pipeline-Depth 0;" > /etc/apt/apt.conf.d/99custom && \
|
|
||||||
echo "Acquire::http::No-Cache true;" >> /etc/apt/apt.conf.d/99custom && \
|
|
||||||
echo "Acquire::BrokenProxy true;" >> /etc/apt/apt.conf.d/99custom
|
|
||||||
|
|
||||||
RUN apt-get update && apt-get upgrade -y \
|
|
||||||
&& mkdir -p /etc/apt/keyrings \
|
|
||||||
&& apt-get install -y gnupg gosu curl ca-certificates zip unzip git supervisor sqlite3 libcap2-bin libpng-dev python3 dnsutils librsvg2-bin fswatch ffmpeg nano \
|
|
||||||
&& curl -sS 'https://keyserver.ubuntu.com/pks/lookup?op=get&search=0xb8dc7e53946656efbce4c1dd71daeaab4ad4cab6' | gpg --dearmor | tee /usr/share/keyrings/ppa_ondrej_php.gpg > /dev/null \
|
|
||||||
&& echo "deb [signed-by=/usr/share/keyrings/ppa_ondrej_php.gpg] https://ppa.launchpadcontent.net/ondrej/php/ubuntu noble main" > /etc/apt/sources.list.d/ppa_ondrej_php.list \
|
|
||||||
&& apt-get update \
|
|
||||||
&& apt-get install -y php8.0-cli php8.0-dev \
|
|
||||||
php8.0-pgsql php8.0-sqlite3 php8.0-gd php8.0-imagick \
|
|
||||||
php8.0-curl php8.0-memcached php8.0-mongodb \
|
|
||||||
php8.0-imap php8.0-mysql php8.0-mbstring \
|
|
||||||
php8.0-xml php8.0-zip php8.0-bcmath php8.0-soap \
|
|
||||||
php8.0-intl php8.0-readline php8.0-pcov \
|
|
||||||
php8.0-msgpack php8.0-igbinary php8.0-ldap \
|
|
||||||
php8.0-redis php8.0-swoole php8.0-xdebug \
|
|
||||||
&& curl -sLS https://getcomposer.org/installer | php -- --install-dir=/usr/bin/ --filename=composer \
|
|
||||||
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
|
|
||||||
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_VERSION.x nodistro main" > /etc/apt/sources.list.d/nodesource.list \
|
|
||||||
&& apt-get update \
|
|
||||||
&& apt-get install -y nodejs \
|
|
||||||
&& npm install -g npm \
|
|
||||||
&& npm install -g bun \
|
|
||||||
&& curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | gpg --dearmor | tee /usr/share/keyrings/yarnkey.gpg >/dev/null \
|
|
||||||
&& echo "deb [signed-by=/usr/share/keyrings/yarnkey.gpg] https://dl.yarnpkg.com/debian/ stable main" > /etc/apt/sources.list.d/yarn.list \
|
|
||||||
&& curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor | tee /usr/share/keyrings/pgdg.gpg >/dev/null \
|
|
||||||
&& echo "deb [signed-by=/usr/share/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt noble-pgdg main" > /etc/apt/sources.list.d/pgdg.list \
|
|
||||||
&& apt-get update \
|
|
||||||
&& apt-get install -y yarn \
|
|
||||||
&& apt-get install -y mysql-client \
|
|
||||||
&& apt-get install -y postgresql-client-$POSTGRES_VERSION \
|
|
||||||
&& apt-get -y autoremove \
|
|
||||||
&& apt-get clean \
|
|
||||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
|
||||||
|
|
||||||
RUN update-alternatives --set php /usr/bin/php8.0
|
|
||||||
|
|
||||||
RUN setcap "cap_net_bind_service=+ep" /usr/bin/php8.0
|
|
||||||
|
|
||||||
RUN userdel -r ubuntu
|
|
||||||
RUN groupadd --force -g $WWWGROUP sail
|
|
||||||
RUN useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1337 sail
|
|
||||||
|
|
||||||
COPY start-container /usr/local/bin/start-container
|
|
||||||
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
|
||||||
COPY php.ini /etc/php/8.0/cli/conf.d/99-sail.ini
|
|
||||||
RUN chmod +x /usr/local/bin/start-container
|
|
||||||
|
|
||||||
EXPOSE 80/tcp
|
|
||||||
|
|
||||||
ENTRYPOINT ["start-container"]
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
[PHP]
|
|
||||||
post_max_size = 100M
|
|
||||||
upload_max_filesize = 100M
|
|
||||||
variables_order = EGPCS
|
|
||||||
pcov.directory = .
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
if [ "$SUPERVISOR_PHP_USER" != "root" ] && [ "$SUPERVISOR_PHP_USER" != "sail" ]; then
|
|
||||||
echo "You should set SUPERVISOR_PHP_USER to either 'sail' or 'root'."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -z "$WWWUSER" ]; then
|
|
||||||
usermod -u $WWWUSER sail
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -d /.composer ]; then
|
|
||||||
mkdir /.composer
|
|
||||||
fi
|
|
||||||
|
|
||||||
chmod -R ugo+rw /.composer
|
|
||||||
|
|
||||||
if [ $# -gt 0 ]; then
|
|
||||||
if [ "$SUPERVISOR_PHP_USER" = "root" ]; then
|
|
||||||
exec "$@"
|
|
||||||
else
|
|
||||||
exec gosu $WWWUSER "$@"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
|
|
||||||
fi
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
[supervisord]
|
|
||||||
nodaemon=true
|
|
||||||
user=root
|
|
||||||
logfile=/var/log/supervisor/supervisord.log
|
|
||||||
pidfile=/var/run/supervisord.pid
|
|
||||||
|
|
||||||
[program:php]
|
|
||||||
command=%(ENV_SUPERVISOR_PHP_COMMAND)s
|
|
||||||
user=%(ENV_SUPERVISOR_PHP_USER)s
|
|
||||||
environment=LARAVEL_SAIL="1"
|
|
||||||
stdout_logfile=/dev/stdout
|
|
||||||
stdout_logfile_maxbytes=0
|
|
||||||
stderr_logfile=/dev/stderr
|
|
||||||
stderr_logfile_maxbytes=0
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
FROM ubuntu:24.04
|
|
||||||
|
|
||||||
LABEL maintainer="Taylor Otwell"
|
|
||||||
|
|
||||||
ARG WWWGROUP
|
|
||||||
ARG NODE_VERSION=22
|
|
||||||
ARG POSTGRES_VERSION=17
|
|
||||||
|
|
||||||
WORKDIR /var/www/html
|
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
|
||||||
ENV TZ=UTC
|
|
||||||
ENV SUPERVISOR_PHP_COMMAND="/usr/bin/php -d variables_order=EGPCS /var/www/html/artisan serve --host=0.0.0.0 --port=80"
|
|
||||||
ENV SUPERVISOR_PHP_USER="sail"
|
|
||||||
|
|
||||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
|
||||||
|
|
||||||
RUN echo "Acquire::http::Pipeline-Depth 0;" > /etc/apt/apt.conf.d/99custom && \
|
|
||||||
echo "Acquire::http::No-Cache true;" >> /etc/apt/apt.conf.d/99custom && \
|
|
||||||
echo "Acquire::BrokenProxy true;" >> /etc/apt/apt.conf.d/99custom
|
|
||||||
|
|
||||||
RUN apt-get update && apt-get upgrade -y \
|
|
||||||
&& mkdir -p /etc/apt/keyrings \
|
|
||||||
&& apt-get install -y gnupg gosu curl ca-certificates zip unzip git supervisor sqlite3 libcap2-bin libpng-dev python3 dnsutils librsvg2-bin fswatch ffmpeg nano \
|
|
||||||
&& curl -sS 'https://keyserver.ubuntu.com/pks/lookup?op=get&search=0xb8dc7e53946656efbce4c1dd71daeaab4ad4cab6' | gpg --dearmor | tee /usr/share/keyrings/ppa_ondrej_php.gpg > /dev/null \
|
|
||||||
&& echo "deb [signed-by=/usr/share/keyrings/ppa_ondrej_php.gpg] https://ppa.launchpadcontent.net/ondrej/php/ubuntu noble main" > /etc/apt/sources.list.d/ppa_ondrej_php.list \
|
|
||||||
&& apt-get update \
|
|
||||||
&& apt-get install -y php8.1-cli php8.1-dev \
|
|
||||||
php8.1-pgsql php8.1-sqlite3 php8.1-gd php8.1-imagick \
|
|
||||||
php8.1-curl php8.1-mongodb \
|
|
||||||
php8.1-imap php8.1-mysql php8.1-mbstring \
|
|
||||||
php8.1-xml php8.1-zip php8.1-bcmath php8.1-soap \
|
|
||||||
php8.1-intl php8.1-readline \
|
|
||||||
php8.1-ldap \
|
|
||||||
php8.1-msgpack php8.1-igbinary php8.1-redis php8.1-swoole \
|
|
||||||
php8.1-memcached php8.1-pcov php8.1-xdebug \
|
|
||||||
&& curl -sLS https://getcomposer.org/installer | php -- --install-dir=/usr/bin/ --filename=composer \
|
|
||||||
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
|
|
||||||
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_VERSION.x nodistro main" > /etc/apt/sources.list.d/nodesource.list \
|
|
||||||
&& apt-get update \
|
|
||||||
&& apt-get install -y nodejs \
|
|
||||||
&& npm install -g npm \
|
|
||||||
&& npm install -g bun \
|
|
||||||
&& curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | gpg --dearmor | tee /usr/share/keyrings/yarn.gpg >/dev/null \
|
|
||||||
&& echo "deb [signed-by=/usr/share/keyrings/yarn.gpg] https://dl.yarnpkg.com/debian/ stable main" > /etc/apt/sources.list.d/yarn.list \
|
|
||||||
&& curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor | tee /usr/share/keyrings/pgdg.gpg >/dev/null \
|
|
||||||
&& echo "deb [signed-by=/usr/share/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt noble-pgdg main" > /etc/apt/sources.list.d/pgdg.list \
|
|
||||||
&& apt-get update \
|
|
||||||
&& apt-get install -y yarn \
|
|
||||||
&& apt-get install -y mysql-client \
|
|
||||||
&& apt-get install -y postgresql-client-$POSTGRES_VERSION \
|
|
||||||
&& apt-get -y autoremove \
|
|
||||||
&& apt-get clean \
|
|
||||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
|
||||||
|
|
||||||
RUN setcap "cap_net_bind_service=+ep" /usr/bin/php8.1
|
|
||||||
|
|
||||||
RUN userdel -r ubuntu
|
|
||||||
RUN groupadd --force -g $WWWGROUP sail
|
|
||||||
RUN useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1337 sail
|
|
||||||
|
|
||||||
COPY start-container /usr/local/bin/start-container
|
|
||||||
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
|
||||||
COPY php.ini /etc/php/8.1/cli/conf.d/99-sail.ini
|
|
||||||
RUN chmod +x /usr/local/bin/start-container
|
|
||||||
|
|
||||||
EXPOSE 80/tcp
|
|
||||||
|
|
||||||
ENTRYPOINT ["start-container"]
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
[PHP]
|
|
||||||
post_max_size = 100M
|
|
||||||
upload_max_filesize = 100M
|
|
||||||
variables_order = EGPCS
|
|
||||||
pcov.directory = .
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
if [ "$SUPERVISOR_PHP_USER" != "root" ] && [ "$SUPERVISOR_PHP_USER" != "sail" ]; then
|
|
||||||
echo "You should set SUPERVISOR_PHP_USER to either 'sail' or 'root'."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -z "$WWWUSER" ]; then
|
|
||||||
usermod -u $WWWUSER sail
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -d /.composer ]; then
|
|
||||||
mkdir /.composer
|
|
||||||
fi
|
|
||||||
|
|
||||||
chmod -R ugo+rw /.composer
|
|
||||||
|
|
||||||
if [ $# -gt 0 ]; then
|
|
||||||
if [ "$SUPERVISOR_PHP_USER" = "root" ]; then
|
|
||||||
exec "$@"
|
|
||||||
else
|
|
||||||
exec gosu $WWWUSER "$@"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
|
|
||||||
fi
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
[supervisord]
|
|
||||||
nodaemon=true
|
|
||||||
user=root
|
|
||||||
logfile=/var/log/supervisor/supervisord.log
|
|
||||||
pidfile=/var/run/supervisord.pid
|
|
||||||
|
|
||||||
[program:php]
|
|
||||||
command=%(ENV_SUPERVISOR_PHP_COMMAND)s
|
|
||||||
user=%(ENV_SUPERVISOR_PHP_USER)s
|
|
||||||
environment=LARAVEL_SAIL="1"
|
|
||||||
stdout_logfile=/dev/stdout
|
|
||||||
stdout_logfile_maxbytes=0
|
|
||||||
stderr_logfile=/dev/stderr
|
|
||||||
stderr_logfile_maxbytes=0
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
FROM ubuntu:24.04
|
|
||||||
|
|
||||||
LABEL maintainer="Taylor Otwell"
|
|
||||||
|
|
||||||
ARG WWWGROUP
|
|
||||||
ARG NODE_VERSION=22
|
|
||||||
ARG POSTGRES_VERSION=17
|
|
||||||
|
|
||||||
WORKDIR /var/www/html
|
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
|
||||||
ENV TZ=UTC
|
|
||||||
ENV SUPERVISOR_PHP_COMMAND="/usr/bin/php -d variables_order=EGPCS /var/www/html/artisan serve --host=0.0.0.0 --port=80"
|
|
||||||
ENV SUPERVISOR_PHP_USER="sail"
|
|
||||||
|
|
||||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
|
||||||
|
|
||||||
RUN echo "Acquire::http::Pipeline-Depth 0;" > /etc/apt/apt.conf.d/99custom && \
|
|
||||||
echo "Acquire::http::No-Cache true;" >> /etc/apt/apt.conf.d/99custom && \
|
|
||||||
echo "Acquire::BrokenProxy true;" >> /etc/apt/apt.conf.d/99custom
|
|
||||||
|
|
||||||
RUN apt-get update && apt-get upgrade -y \
|
|
||||||
&& mkdir -p /etc/apt/keyrings \
|
|
||||||
&& apt-get install -y gnupg gosu curl ca-certificates zip unzip git supervisor sqlite3 libcap2-bin libpng-dev python3 dnsutils librsvg2-bin fswatch ffmpeg nano \
|
|
||||||
&& curl -sS 'https://keyserver.ubuntu.com/pks/lookup?op=get&search=0xb8dc7e53946656efbce4c1dd71daeaab4ad4cab6' | gpg --dearmor | tee /etc/apt/keyrings/ppa_ondrej_php.gpg > /dev/null \
|
|
||||||
&& echo "deb [signed-by=/etc/apt/keyrings/ppa_ondrej_php.gpg] https://ppa.launchpadcontent.net/ondrej/php/ubuntu noble main" > /etc/apt/sources.list.d/ppa_ondrej_php.list \
|
|
||||||
&& apt-get update \
|
|
||||||
&& apt-get install -y php8.2-cli php8.2-dev \
|
|
||||||
php8.2-pgsql php8.2-sqlite3 php8.2-gd php8.2-imagick \
|
|
||||||
php8.2-curl php8.2-mongodb \
|
|
||||||
php8.2-imap php8.2-mysql php8.2-mbstring \
|
|
||||||
php8.2-xml php8.2-zip php8.2-bcmath php8.2-soap \
|
|
||||||
php8.2-intl php8.2-readline \
|
|
||||||
php8.2-ldap \
|
|
||||||
php8.2-msgpack php8.2-igbinary php8.2-redis php8.2-swoole \
|
|
||||||
php8.2-memcached php8.2-pcov php8.2-xdebug \
|
|
||||||
&& curl -sLS https://getcomposer.org/installer | php -- --install-dir=/usr/bin/ --filename=composer \
|
|
||||||
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
|
|
||||||
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_VERSION.x nodistro main" > /etc/apt/sources.list.d/nodesource.list \
|
|
||||||
&& apt-get update \
|
|
||||||
&& apt-get install -y nodejs \
|
|
||||||
&& npm install -g npm \
|
|
||||||
&& npm install -g pnpm \
|
|
||||||
&& npm install -g bun \
|
|
||||||
&& curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | gpg --dearmor | tee /etc/apt/keyrings/yarn.gpg >/dev/null \
|
|
||||||
&& echo "deb [signed-by=/etc/apt/keyrings/yarn.gpg] https://dl.yarnpkg.com/debian/ stable main" > /etc/apt/sources.list.d/yarn.list \
|
|
||||||
&& curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor | tee /etc/apt/keyrings/pgdg.gpg >/dev/null \
|
|
||||||
&& echo "deb [signed-by=/etc/apt/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt noble-pgdg main" > /etc/apt/sources.list.d/pgdg.list \
|
|
||||||
&& apt-get update \
|
|
||||||
&& apt-get install -y yarn \
|
|
||||||
&& apt-get install -y mysql-client \
|
|
||||||
&& apt-get install -y postgresql-client-$POSTGRES_VERSION \
|
|
||||||
&& apt-get -y autoremove \
|
|
||||||
&& apt-get clean \
|
|
||||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
|
||||||
|
|
||||||
RUN setcap "cap_net_bind_service=+ep" /usr/bin/php8.2
|
|
||||||
|
|
||||||
RUN userdel -r ubuntu
|
|
||||||
RUN groupadd --force -g $WWWGROUP sail
|
|
||||||
RUN useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1337 sail
|
|
||||||
|
|
||||||
COPY start-container /usr/local/bin/start-container
|
|
||||||
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
|
||||||
COPY php.ini /etc/php/8.2/cli/conf.d/99-sail.ini
|
|
||||||
RUN chmod +x /usr/local/bin/start-container
|
|
||||||
|
|
||||||
EXPOSE 80/tcp
|
|
||||||
|
|
||||||
ENTRYPOINT ["start-container"]
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
[PHP]
|
|
||||||
post_max_size = 100M
|
|
||||||
upload_max_filesize = 100M
|
|
||||||
variables_order = EGPCS
|
|
||||||
pcov.directory = .
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
if [ "$SUPERVISOR_PHP_USER" != "root" ] && [ "$SUPERVISOR_PHP_USER" != "sail" ]; then
|
|
||||||
echo "You should set SUPERVISOR_PHP_USER to either 'sail' or 'root'."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -z "$WWWUSER" ]; then
|
|
||||||
usermod -u $WWWUSER sail
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -d /.composer ]; then
|
|
||||||
mkdir /.composer
|
|
||||||
fi
|
|
||||||
|
|
||||||
chmod -R ugo+rw /.composer
|
|
||||||
|
|
||||||
if [ $# -gt 0 ]; then
|
|
||||||
if [ "$SUPERVISOR_PHP_USER" = "root" ]; then
|
|
||||||
exec "$@"
|
|
||||||
else
|
|
||||||
exec gosu $WWWUSER "$@"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
|
|
||||||
fi
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
[supervisord]
|
|
||||||
nodaemon=true
|
|
||||||
user=root
|
|
||||||
logfile=/var/log/supervisor/supervisord.log
|
|
||||||
pidfile=/var/run/supervisord.pid
|
|
||||||
|
|
||||||
[program:php]
|
|
||||||
command=%(ENV_SUPERVISOR_PHP_COMMAND)s
|
|
||||||
user=%(ENV_SUPERVISOR_PHP_USER)s
|
|
||||||
environment=LARAVEL_SAIL="1"
|
|
||||||
stdout_logfile=/dev/stdout
|
|
||||||
stdout_logfile_maxbytes=0
|
|
||||||
stderr_logfile=/dev/stderr
|
|
||||||
stderr_logfile_maxbytes=0
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue