10.April 2026

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

View file

@ -0,0 +1,169 @@
<?php
namespace App\Console\Commands;
use App\Models\AboChartSnapshot;
use App\Models\UserAbo;
use App\Services\AboHelper;
use App\User;
use Carbon\Carbon;
use Illuminate\Console\Command;
class AboStoreChartSnapshots extends Command
{
protected $signature = 'abo:store-chart-snapshots
{--user= : Nur einen bestimmten User berechnen (user_id)}
{--force : Bereits vorhandene Snapshots überschreiben}';
protected $description = 'Speichert monatliche Abo-Zählungen aller vergangenen Monate in der Datenbank (einmalig je Monat)';
private const SCOPES = ['ot', 'team_abos', 'team_cust_abos'];
private const START_YEAR = 2026;
public function handle(): int
{
$now = Carbon::now();
$force = (bool) $this->option('force');
// Monate die eingefroren werden sollen: von START_YEAR/01 bis letzten Monat
$months = $this->getPastMonths($now);
if (empty($months)) {
$this->info('Keine vergangenen Monate zum Speichern.');
return self::SUCCESS;
}
// User ermitteln
$userQuery = User::whereNotNull('m_level')
->whereNotNull('payment_account')
->where('admin', '<', 4)
->whereNull('deleted_at');
if ($userId = $this->option('user')) {
$userQuery->where('id', $userId);
}
$users = $userQuery->select('id')->get();
$total = $users->count();
$this->info("Berechne Snapshots für {$total} User, ".count($months).' Monate, '.count(self::SCOPES).' Scopes...');
$bar = $this->output->createProgressBar($total);
$bar->start();
$inserted = 0;
$skipped = 0;
foreach ($users as $user) {
// Bereits vorhandene Snapshots für diesen User laden (zum Überspringen)
$existing = AboChartSnapshot::where('user_id', $user->id)
->get()
->keyBy(fn ($s) => "{$s->scope}_{$s->year}_{$s->month}");
$teamUserIds = AboHelper::getTeamUserIds($user->id);
$rows = [];
foreach ($months as [$year, $month]) {
$startOfMonth = Carbon::create($year, $month, 1)->startOfMonth();
$endOfMonth = Carbon::create($year, $month, 1)->endOfMonth();
foreach (self::SCOPES as $scope) {
$key = "{$scope}_{$year}_{$month}";
if (! $force && $existing->has($key)) {
$skipped++;
continue;
}
$count = $this->calculateCount($scope, $user->id, $teamUserIds, $startOfMonth, $endOfMonth);
$rows[] = [
'user_id' => $user->id,
'scope' => $scope,
'year' => $year,
'month' => $month,
'count' => $count,
'calculated_at' => $now,
'created_at' => $now,
'updated_at' => $now,
];
$inserted++;
}
}
if (! empty($rows)) {
if ($force) {
foreach ($rows as $row) {
AboChartSnapshot::updateOrInsert(
['user_id' => $row['user_id'], 'scope' => $row['scope'], 'year' => $row['year'], 'month' => $row['month']],
$row
);
}
} else {
AboChartSnapshot::insertOrIgnore($rows);
}
}
$bar->advance();
gc_collect_cycles();
}
$bar->finish();
$this->newLine();
$this->info("Fertig. Gespeichert: {$inserted}, Übersprungen (bereits vorhanden): {$skipped}");
return self::SUCCESS;
}
/**
* Berechnet die Abo-Anzahl für einen Scope/User/Monat anhand der tatsächlichen Daten zum Zeitpunkt der Berechnung.
*
* @param int[] $teamUserIds
*/
private function calculateCount(string $scope, int $userId, array $teamUserIds, Carbon $startOfMonth, Carbon $endOfMonth): int
{
$terminalStatuses = [4, 5];
$query = match ($scope) {
'ot' => UserAbo::where('member_id', $userId)
->where('is_for', 'ot')
->where('status', '>', 1),
'team_abos' => UserAbo::whereIn('user_id', $teamUserIds)
->where('is_for', 'me')
->where('status', '>', 1),
'team_cust_abos' => UserAbo::whereIn('member_id', $teamUserIds)
->where('is_for', 'ot')
->where('status', '>', 1),
};
return $query
->whereDate('start_date', '<=', $endOfMonth)
->where(function ($q) use ($startOfMonth, $terminalStatuses) {
$q->whereDate('cancel_date', '>=', $startOfMonth)
->orWhere(function ($q2) use ($terminalStatuses) {
$q2->whereNull('cancel_date')
->whereNotIn('status', $terminalStatuses);
});
})
->count();
}
/**
* Alle abgeschlossenen Monate von START_YEAR/01 bis letzten Monat.
*
* @return array<array{int, int}>
*/
private function getPastMonths(Carbon $now): array
{
$months = [];
$cursor = Carbon::create(self::START_YEAR, 1, 1);
$lastMonth = $now->copy()->subMonth()->endOfMonth();
while ($cursor->lte($lastMonth)) {
$months[] = [(int) $cursor->year, (int) $cursor->month];
$cursor->addMonth();
}
return $months;
}
}

View file

@ -8,6 +8,9 @@ use App\Cron\UserPaymentCredits;
use App\Models\Setting;
use App\Models\UserBusiness;
use App\Models\UserBusinessStructure;
use App\Models\UserSalesVolume;
use App\Services\BusinessPlan\SalesPointsVolume;
use App\User;
use Illuminate\Console\Command;
class BusinessStoreOptimized extends Command
@ -163,6 +166,10 @@ class BusinessStoreOptimized extends Command
$this->userLevelUpdate();
});
$this->executeWithErrorHandling('Monthly Qual-KP Bonus Points', function () {
\Log::channel('cron')->info('RUN Command BusinessStoreOptimized Monthly Qual-KP Bonus Points');
$this->assignMonthlyQualKpBonusPoints();
});
// Auskommentierte Prozesse bleiben inaktiv
// $this->userCreatePaymentCreditsPDF();
// $this->storeBusinessStructureUsersDetailPeriod(1, 6);
@ -377,6 +384,58 @@ class BusinessStoreOptimized extends Command
}
}
/**
* Schreibt ausgewählten Usern einmal pro Monat ihre Level-qual_kp als KP-Bonus gut.
* Idempotent: Ein bereits vorhandener Eintrag für diesen Monat/Jahr wird übersprungen.
*
* @var array<int> User-IDs die den monatlichen Qual-KP-Bonus erhalten sollen
*/
private function assignMonthlyQualKpBonusPoints(): void
{
$bonusUserIds = [486];
$month = date('m');
$year = date('Y');
$users = User::query()
->whereIn('id', $bonusUserIds)
->whereNotNull('m_level')
->with('user_level')
->get()
->filter(fn (User $user) => $user->user_level && $user->user_level->qual_kp > 0);
$assigned = 0;
$skipped = 0;
foreach ($users as $user) {
$alreadyExists = UserSalesVolume::where('user_id', $user->id)
->where('month', $month)
->where('year', $year)
->where('info', 'qual_kp_bonus')
->exists();
if ($alreadyExists) {
$skipped++;
continue;
}
SalesPointsVolume::addSalesPointsVolume([
'user_id' => $user->id,
'points' => $user->user_level->qual_kp,
'status_points' => 2,
'total_net' => 0,
'status_turnover' => 1,
'info' => 'qual_kp_bonus',
]);
$assigned++;
}
$this->info("Qual-KP Bonus: {$assigned} zugewiesen, {$skipped} übersprungen (bereits vorhanden)");
\Log::channel('cron')->info("Qual-KP Bonus Points: assigned={$assigned}, skipped={$skipped}");
}
private function logExecutionTime($message)
{
$diff = microtime(true) - $this->timeStart;

View file

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

View file

@ -0,0 +1,196 @@
<?php
namespace App\Console\Commands;
use App\Models\Incentive;
use App\Models\IncentiveNewPartner;
use App\Models\IncentiveParticipant;
use App\Models\ShoppingOrder;
use App\User;
use Illuminate\Console\Command;
class IncentiveDebugTrackPartner extends Command
{
protected $signature = 'incentive:debug-track-partner {order_id : Shopping Order ID}';
protected $description = 'Debuggt trackNewPartner Schritt fuer Schritt fuer eine bestimmte Bestellung';
public function handle(): int
{
$order_id = $this->argument('order_id');
$shopping_order = ShoppingOrder::find($order_id);
if (! $shopping_order) {
$this->error("Shopping Order #{$order_id} nicht gefunden.");
return self::FAILURE;
}
$this->info("=== Debug trackNewPartner fuer Order #{$order_id} ===");
$this->newLine();
// 1. Bestelldaten
$this->info('[1] Bestelldaten:');
$this->table(['Feld', 'Wert'], [
['id', $shopping_order->id],
['auth_user_id', $shopping_order->auth_user_id ?? 'NULL'],
['member_id', $shopping_order->member_id ?? 'NULL'],
['payment_for', $shopping_order->payment_for],
['paid', $shopping_order->paid],
['txaction', $shopping_order->txaction],
['mode', $shopping_order->mode],
['created_at', $shopping_order->created_at],
]);
// 2. Prüfe payment_for == 1 (Voraussetzung im Payment.php)
if ($shopping_order->payment_for != 1) {
$this->warn("[!] payment_for = {$shopping_order->payment_for} (nicht 1/registration). trackNewPartner wird nur bei payment_for=1 aufgerufen!");
}
// 3. Neuer User
$this->newLine();
$this->info('[2] Neuer User (auth_user_id):');
if (! $shopping_order->auth_user_id) {
$this->error(' auth_user_id ist NULL -> ABBRUCH (return)');
return self::SUCCESS;
}
$new_user = User::find($shopping_order->auth_user_id);
if (! $new_user) {
$this->error(" User #{$shopping_order->auth_user_id} nicht gefunden -> ABBRUCH (return)");
return self::SUCCESS;
}
$this->table(['Feld', 'Wert'], [
['id', $new_user->id],
['email', $new_user->email],
['m_sponsor', $new_user->m_sponsor ?? 'NULL'],
['active', $new_user->active],
['created_at', $new_user->created_at],
]);
if (! $new_user->m_sponsor) {
$this->error(' m_sponsor ist NULL -> ABBRUCH (return)');
return self::SUCCESS;
}
$sponsor_id = $new_user->m_sponsor;
$this->info(" Sponsor ID: {$sponsor_id}");
// 4. Registration Date
$registration_date = $shopping_order->created_at;
$this->newLine();
$this->info("[3] Registration Date: {$registration_date}");
// 5. Aktive Incentives
$this->newLine();
$this->info('[4] Aktive Incentives pruefen:');
$all_incentives = Incentive::query()->get();
$this->info(" Incentives gesamt: {$all_incentives->count()}");
foreach ($all_incentives as $incentive) {
$is_active = $incentive->status == 1;
$in_range = $registration_date >= $incentive->qualification_start
&& $registration_date <= $incentive->qualification_end;
$status_icon = $is_active ? 'AKTIV' : 'INAKTIV';
$range_icon = $in_range ? 'IM ZEITRAUM' : 'AUSSERHALB';
$this->table(['Feld', 'Wert'], [
['Incentive', "#{$incentive->id}: {$incentive->name}"],
['Status', "{$incentive->status} ({$status_icon})"],
['qualification_start', $incentive->qualification_start],
['qualification_end', $incentive->qualification_end],
['Registration Date', "{$registration_date} ({$range_icon})"],
]);
if (! $is_active) {
$this->warn(' -> Uebersprungen: Incentive nicht aktiv');
continue;
}
if (! $in_range) {
$this->warn(' -> Uebersprungen: Registration Date ausserhalb Qualifikationszeitraum');
continue;
}
$this->info(" -> MATCH! Incentive #{$incentive->id} ist aktiv und Registration Date liegt im Zeitraum.");
// 6. Participant prüfen
$this->newLine();
$this->info("[5] Participant-Check: Sponsor #{$sponsor_id} in Incentive #{$incentive->id}");
$participant = IncentiveParticipant::where('incentive_id', $incentive->id)
->where('user_id', $sponsor_id)
->first();
if (! $participant) {
$this->error(" Sponsor #{$sponsor_id} ist KEIN Teilnehmer in Incentive #{$incentive->id} -> SKIP");
// Zeige alle Teilnehmer-User-IDs
$participant_ids = IncentiveParticipant::where('incentive_id', $incentive->id)
->pluck('user_id')
->toArray();
$this->info(' Teilnehmer User-IDs: '.implode(', ', array_slice($participant_ids, 0, 20))
.(count($participant_ids) > 20 ? '... (+'.count($participant_ids) - 20 .')' : ''));
continue;
}
$this->info(" Participant gefunden: #{$participant->id} (User #{$participant->user_id})");
$this->table(['Feld', 'Wert'], [
['participant.id', $participant->id],
['user_id', $participant->user_id],
['total_points', $participant->total_points],
['qualified_partners', $participant->qualified_partners],
['accepted_terms_at', $participant->accepted_terms_at ?? 'NULL'],
]);
// 7. Tracking-Eintrag prüfen
$this->newLine();
$this->info('[6] Tracking-Eintrag (incentive_new_partners):');
$existing = IncentiveNewPartner::where('participant_id', $participant->id)
->where('user_id', $new_user->id)
->first();
if ($existing) {
$this->warn(" Eintrag existiert bereits: #{$existing->id} (erstellt: {$existing->created_at})");
} else {
$this->info(' Kein Eintrag vorhanden -> wuerde neu erstellt werden.');
}
// 8. Zusammenfassung
$this->newLine();
$this->info('=== ERGEBNIS ===');
$this->info('trackNewPartner WUERDE erfolgreich laufen fuer:');
$this->info(" Neuer Partner: User #{$new_user->id} ({$new_user->email})");
$this->info(" Sponsor/Teilnehmer: User #{$sponsor_id} (Participant #{$participant->id})");
$this->info(" Incentive: #{$incentive->id} ({$incentive->name})");
$this->info(" Einmalpunkte: {$incentive->points_partner_onetime}");
}
// Prüfe den Query wie er im Code steht
$this->newLine();
$this->info('[7] Exakter Query wie im Code:');
$matched_incentives = Incentive::query()
->active()
->where('qualification_start', '<=', $registration_date)
->where('qualification_end', '>=', $registration_date)
->get();
$this->info(" Incentive::active()->where(start <= {$registration_date})->where(end >= {$registration_date})");
$this->info(" Ergebnis: {$matched_incentives->count()} Incentive(s)");
foreach ($matched_incentives as $mi) {
$this->info(" -> #{$mi->id}: {$mi->name}");
}
return self::SUCCESS;
}
}

View file

@ -0,0 +1,241 @@
<?php
namespace App\Console\Commands;
use App\Models\Incentive;
use App\Models\IncentiveNewAbo;
use App\Models\IncentiveNewPartner;
use App\Models\IncentivePointsLog;
use App\Models\ShoppingOrder;
use App\Models\UserSalesVolume;
use Illuminate\Console\Command;
class IncentiveDebugTrackSalesVolume extends Command
{
protected $signature = 'incentive:debug-track-sv {sv_id : UserSalesVolume ID}';
protected $description = 'Debuggt trackSalesVolume Schritt fuer Schritt fuer einen bestimmten SalesVolume-Eintrag';
public function handle(): int
{
$sv_id = $this->argument('sv_id');
$usv = UserSalesVolume::find($sv_id);
if (! $usv) {
$this->error("UserSalesVolume #{$sv_id} nicht gefunden.");
return self::FAILURE;
}
$this->info("=== Debug trackSalesVolume fuer USV #{$sv_id} ===");
$this->newLine();
// 1. SalesVolume-Daten
$this->info('[1] SalesVolume-Daten:');
$this->table(['Feld', 'Wert'], [
['id', $usv->id],
['user_id', $usv->user_id ?? 'NULL'],
['shopping_order_id', $usv->shopping_order_id ?? 'NULL'],
['user_invoice_id', $usv->user_invoice_id ?? 'NULL'],
['month', $usv->month ?? 'NULL'],
['year', $usv->year ?? 'NULL'],
['points', $usv->getRawOriginal('points') ?? 'NULL'],
['status', $usv->status.' ('.($usv->getStatusType() ?: '-').')'],
['status_points', $usv->status_points ?? 'NULL'],
['status_turnover', $usv->status_turnover ?? 'NULL'],
['message', $usv->message ?? 'NULL'],
]);
// 2. Fruehe Abbruch-Checks
$month = $usv->month;
$year = $usv->year;
if (! $month || ! $year) {
$this->error('[ABBRUCH] month oder year ist NULL -> return');
return self::SUCCESS;
}
$points = (int) abs($usv->getRawOriginal('points') ?? 0);
if ($points <= 0) {
$this->error("[ABBRUCH] points = {$points} (<= 0) -> return");
return self::SUCCESS;
}
$this->info(" Effektive Punkte: {$points}");
// 3. Aktive Incentives
$this->newLine();
$this->info('[2] Aktive Incentives:');
$active_incentives = Incentive::query()->active()->get();
$this->info(" Anzahl aktive: {$active_incentives->count()}");
foreach ($active_incentives as $incentive) {
$in_scope = $incentive->isDateInScope($month, $year);
$scope_label = $in_scope ? 'IM SCOPE' : 'AUSSERHALB';
$this->info(" #{$incentive->id} {$incentive->name}: {$month}/{$year} -> {$scope_label}");
$this->info(" Qualification: {$incentive->qualification_start} - {$incentive->qualification_end}, Calc End: {$incentive->calculation_end}");
}
// ===== TEIL A: Neupartner-Check =====
$this->newLine();
$this->info('========================================');
$this->info('[A] NEUPARTNER-CHECK: Ist User #'.$usv->user_id.' ein gettrackter Neupartner?');
$this->info('========================================');
$partner_trackings = IncentiveNewPartner::where('user_id', $usv->user_id)
->with('participant.incentive')
->get();
$this->info(" IncentiveNewPartner-Eintraege fuer user_id={$usv->user_id}: {$partner_trackings->count()}");
if ($partner_trackings->isEmpty()) {
$this->warn(' -> User ist KEIN gettrackter Neupartner in irgendeinem Incentive.');
}
foreach ($partner_trackings as $tracking) {
$participant = $tracking->participant;
$incentive = $participant->incentive ?? null;
$this->newLine();
$this->table(['Feld', 'Wert'], [
['NewPartner #', $tracking->id],
['participant_id', $tracking->participant_id],
['Participant User', $participant->user_id],
['Incentive', $incentive ? "#{$incentive->id}: {$incentive->name}" : 'NULL'],
['Incentive Status', $incentive ? $incentive->status : 'NULL'],
['Incentive aktiv?', $incentive && $incentive->status == 1 ? 'JA' : 'NEIN'],
]);
if (! $incentive || $incentive->status != 1) {
$this->warn(' -> Incentive nicht aktiv -> SKIP');
continue;
}
$in_scope = $incentive->isDateInScope($month, $year);
$this->info(" isDateInScope({$month}, {$year}): ".($in_scope ? 'JA' : 'NEIN'));
if (! $in_scope) {
$this->warn(' -> Monat/Jahr ausserhalb Scope -> SKIP');
continue;
}
// Duplikat-Check
$exists = IncentivePointsLog::where('participant_id', $participant->id)
->where('user_sales_volume_id', $usv->id)
->where('is_storno', false)
->exists();
$this->info(' Log-Eintrag existiert bereits: '.($exists ? 'JA (Duplikat -> kein neuer Eintrag)' : 'NEIN -> wuerde erstellt'));
$this->info(' ==> MATCH! Punkte wuerden Participant #'.$participant->id." (User #{$participant->user_id}) gutgeschrieben");
$this->info(" Typ: partner, Punkte: {$points} (accumulated)");
}
// ===== TEIL B: Neuabo-Check =====
$this->newLine();
$this->info('========================================');
$this->info('[B] NEUABO-CHECK: Stammt die Bestellung von einem gettrackten Abo-Kunden?');
$this->info('========================================');
if (! $usv->shopping_order_id) {
$this->warn(' shopping_order_id ist NULL -> Abo-Check uebersprungen.');
} else {
$order = ShoppingOrder::find($usv->shopping_order_id);
if (! $order) {
$this->error(" ShoppingOrder #{$usv->shopping_order_id} nicht gefunden.");
} else {
$this->table(['Feld', 'Wert'], [
['Order ID', $order->id],
['shopping_user_id', $order->shopping_user_id ?? 'NULL'],
['auth_user_id', $order->auth_user_id ?? 'NULL'],
['member_id', $order->member_id ?? 'NULL'],
['payment_for', $order->payment_for],
['is_abo', $order->is_abo ? 'JA' : 'NEIN'],
]);
if (! $order->shopping_user_id) {
$this->warn(' shopping_user_id ist NULL -> kein Abo-Matching moeglich.');
} else {
$abo_trackings = IncentiveNewAbo::whereHas(
'userAbo',
fn ($q) => $q->where('shopping_user_id', $order->shopping_user_id)
)
->with('participant.incentive', 'userAbo')
->get();
$this->info(" IncentiveNewAbo mit shopping_user_id={$order->shopping_user_id}: {$abo_trackings->count()}");
if ($abo_trackings->isEmpty()) {
$this->warn(' -> Keine gettrackten Abos fuer diesen Kunden.');
}
foreach ($abo_trackings as $tracking) {
$participant = $tracking->participant;
$incentive = $participant->incentive ?? null;
$abo = $tracking->userAbo;
$this->newLine();
$this->table(['Feld', 'Wert'], [
['NewAbo #', $tracking->id],
['user_abo_id', $tracking->user_abo_id],
['Abo shopping_user_id', $abo ? $abo->shopping_user_id : 'NULL'],
['participant_id', $tracking->participant_id],
['Participant User', $participant->user_id],
['Incentive', $incentive ? "#{$incentive->id}: {$incentive->name}" : 'NULL'],
['Incentive aktiv?', $incentive && $incentive->status == 1 ? 'JA' : 'NEIN'],
]);
if (! $incentive || $incentive->status != 1) {
$this->warn(' -> Incentive nicht aktiv -> SKIP');
continue;
}
$in_scope = $incentive->isDateInScope($month, $year);
$this->info(" isDateInScope({$month}, {$year}): ".($in_scope ? 'JA' : 'NEIN'));
if (! $in_scope) {
$this->warn(' -> Monat/Jahr ausserhalb Scope -> SKIP');
continue;
}
$exists = IncentivePointsLog::where('participant_id', $participant->id)
->where('user_sales_volume_id', $usv->id)
->where('is_storno', false)
->exists();
$this->info(' Log-Eintrag existiert bereits: '.($exists ? 'JA (Duplikat)' : 'NEIN -> wuerde erstellt'));
$this->info(' ==> MATCH! Punkte wuerden Participant #'.$participant->id." (User #{$participant->user_id}) gutgeschrieben");
$this->info(" Typ: abo, Punkte: {$points} (accumulated)");
}
}
}
}
// ===== Zusammenfassung =====
$this->newLine();
$this->info('=== ZUSAMMENFASSUNG ===');
$total_partner = $partner_trackings->filter(function ($t) use ($month, $year) {
return $t->participant->incentive
&& $t->participant->incentive->status == 1
&& $t->participant->incentive->isDateInScope($month, $year);
})->count();
$this->info(" Neupartner-Matches: {$total_partner}");
$this->info(' Neuabo-Matches: siehe oben');
if ($total_partner === 0) {
$this->warn(' -> Keine Punkte wuerden vergeben (kein Match).');
}
return self::SUCCESS;
}
}

View file

@ -0,0 +1,419 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class PayoneFailedPaypalReport extends Command
{
protected $signature = 'payone:failed-paypal-report
{--from=2026-04-02 : Start-Datum (YYYY-MM-DD)}
{--to= : End-Datum (YYYY-MM-DD), Standard: heute}
{--output=storage/reports/paypal-failed-report.csv : Ausgabedatei}';
protected $description = 'Erstellt einen Schadenbericht über fehlgeschlagene PayPal-Zahlungen (Error 923)';
public function handle(): int
{
$from = $this->option('from');
$to = $this->option('to') ?: now()->format('Y-m-d');
$outputPath = $this->option('output');
$this->info("Schadenbericht PayPal-Ausfälle: {$from} bis {$to}");
$this->newLine();
$orders = $this->getAffectedOrders($from, $to);
if ($orders->isEmpty()) {
$this->warn('Keine fehlgeschlagenen PayPal-Zahlungen im angegebenen Zeitraum gefunden.');
return self::SUCCESS;
}
$this->displaySummary($orders, $from, $to);
$fullPath = base_path($outputPath);
$dir = dirname($fullPath);
if (! is_dir($dir)) {
mkdir($dir, 0755, true);
}
$this->writeCsvReport($fullPath, $orders, $from, $to);
$this->writeTxtReport(str_replace('.csv', '.txt', $fullPath), $orders, $from, $to);
$this->writeEmailLists($dir, $orders);
$this->newLine();
$this->info("CSV-Bericht: {$fullPath}");
$this->info('TXT-Bericht: ' . str_replace('.csv', '.txt', $fullPath));
$this->info("E-Mail Berater: {$dir}/emails-berater.csv");
$this->info("E-Mail Shop-Kunden: {$dir}/emails-shop-kunden.csv");
return self::SUCCESS;
}
private function getAffectedOrders(string $from, string $to): \Illuminate\Support\Collection
{
return DB::table('shopping_orders')
->join('shopping_payments', function ($join) {
$join->on('shopping_payments.shopping_order_id', '=', 'shopping_orders.id')
->where('shopping_payments.clearingtype', '=', 'wlt')
->where('shopping_payments.wallettype', '=', 'PPE');
})
->join('payment_transactions', function ($join) {
$join->on('payment_transactions.shopping_payment_id', '=', 'shopping_payments.id')
->where('payment_transactions.errorcode', '=', 923);
})
->join('shopping_users', 'shopping_users.id', '=', 'shopping_orders.shopping_user_id')
->whereBetween('payment_transactions.created_at', ["{$from} 00:00:00", "{$to} 23:59:59"])
->select(
'shopping_orders.id as order_id',
'shopping_orders.total_shipping',
'shopping_orders.paid',
'shopping_orders.txaction',
'shopping_orders.mode',
'shopping_orders.payment_for',
'shopping_orders.auth_user_id',
'shopping_orders.created_at as order_date',
'shopping_users.billing_email',
'shopping_users.billing_firstname',
'shopping_users.billing_lastname',
'shopping_payments.id as payment_id',
'shopping_payments.reference',
'shopping_payments.amount as amount_cents',
'shopping_payments.currency',
'payment_transactions.id as tx_id',
'payment_transactions.errorcode',
'payment_transactions.errormessage',
'payment_transactions.created_at as error_date',
)
->orderBy('payment_transactions.created_at')
->get();
}
private function displaySummary(\Illuminate\Support\Collection $rows, string $from, string $to): void
{
$uniqueOrders = $rows->unique('order_id');
$paidOrders = $uniqueOrders->where('paid', 1);
$unpaidOrders = $uniqueOrders->where('paid', 0);
$this->table(
['Kennzahl', 'Wert'],
[
['Zeitraum', "{$from} bis {$to}"],
['Fehlgeschlagene Transaktionen (Error 923)', $rows->count()],
['Betroffene Bestellungen (eindeutig)', $uniqueOrders->count()],
['Davon nachträglich bezahlt (andere Zahlungsart)', $paidOrders->count()],
['Nicht bezahlt (offen/verloren)', $unpaidOrders->count()],
['Summe nicht bezahlter Bestellungen', number_format($unpaidOrders->sum('total_shipping'), 2, ',', '.') . ' EUR'],
['Summe aller betroffenen Bestellungen', number_format($uniqueOrders->sum('total_shipping'), 2, ',', '.') . ' EUR'],
]
);
}
private function writeCsvReport(string $path, \Illuminate\Support\Collection $rows, string $from, string $to): void
{
$fp = fopen($path, 'w');
fprintf($fp, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($fp, [
'Fehler-Datum',
'Bestell-Nr',
'Bestell-Datum',
'Transaktions-ID',
'Payment-Referenz',
'Betrag (EUR)',
'Fehlercode',
'Fehlermeldung',
'Modus',
'Nachträglich bezahlt',
'Aktueller Status',
], ';');
$uniqueOrders = $rows->unique('order_id');
$unpaidOrders = $uniqueOrders->where('paid', 0);
foreach ($rows as $row) {
fputcsv($fp, [
$row->error_date,
$row->order_id,
$row->order_date,
$row->tx_id,
$row->reference,
number_format($row->total_shipping, 2, ',', ''),
$row->errorcode,
$row->errormessage,
$row->mode,
$row->paid ? 'Ja' : 'Nein',
$row->txaction,
], ';');
}
fputcsv($fp, [], ';');
fputcsv($fp, ['ZUSAMMENFASSUNG'], ';');
fputcsv($fp, ['Zeitraum', "{$from} bis {$to}"], ';');
fputcsv($fp, ['Fehlgeschlagene Transaktionen', $rows->count()], ';');
fputcsv($fp, ['Betroffene Bestellungen', $uniqueOrders->count()], ';');
fputcsv($fp, ['Nicht bezahlt (offen/verloren)', $unpaidOrders->count()], ';');
fputcsv($fp, ['Summe nicht bezahlter Bestellungen', number_format($unpaidOrders->sum('total_shipping'), 2, ',', '') . ' EUR'], ';');
fputcsv($fp, ['Summe aller betroffenen Bestellungen', number_format($uniqueOrders->sum('total_shipping'), 2, ',', '') . ' EUR'], ';');
fclose($fp);
}
private function writeTxtReport(string $path, \Illuminate\Support\Collection $rows, string $from, string $to): void
{
$uniqueOrders = $rows->unique('order_id');
$paidOrders = $uniqueOrders->where('paid', 1);
$unpaidOrders = $uniqueOrders->where('paid', 0);
$lines = [];
$lines[] = '================================================================================';
$lines[] = ' SCHADENBERICHT: Fehlgeschlagene PayPal-Zahlungen (PAYONE Error 923)';
$lines[] = '================================================================================';
$lines[] = '';
$lines[] = "Zeitraum: {$from} bis {$to}";
$lines[] = 'Erstellt am: ' . now()->format('d.m.Y H:i:s');
$lines[] = 'Ursache: PayPal-Kontoverknüpfung bei PAYONE nicht migriert (Vertragsübernahme GmbH)';
$lines[] = 'Portal-ID: 2030693';
$lines[] = 'Merchant-ID: 42504';
$lines[] = 'Sub-Account-ID: 43065';
$lines[] = '';
$lines[] = '--------------------------------------------------------------------------------';
$lines[] = ' ZUSAMMENFASSUNG';
$lines[] = '--------------------------------------------------------------------------------';
$lines[] = '';
$lines[] = sprintf(' Fehlgeschlagene Transaktionen (Error 923): %d', $rows->count());
$lines[] = sprintf(' Betroffene Bestellungen (eindeutig): %d', $uniqueOrders->count());
$lines[] = sprintf(' Davon nachträglich bezahlt (andere Methode): %d', $paidOrders->count());
$lines[] = sprintf(' Nicht bezahlt (offen/verloren): %d', $unpaidOrders->count());
$lines[] = '';
$lines[] = sprintf(' Summe nicht bezahlter Bestellungen: %s EUR', number_format($unpaidOrders->sum('total_shipping'), 2, ',', '.'));
$lines[] = sprintf(' Summe nachträglich bezahlter Bestellungen: %s EUR', number_format($paidOrders->sum('total_shipping'), 2, ',', '.'));
$lines[] = sprintf(' Summe ALLER betroffenen Bestellungen: %s EUR', number_format($uniqueOrders->sum('total_shipping'), 2, ',', '.'));
$lines[] = '';
$lines[] = '--------------------------------------------------------------------------------';
$lines[] = ' AUFSCHLÜSSELUNG NACH TAG';
$lines[] = '--------------------------------------------------------------------------------';
$lines[] = '';
$byDate = $rows->groupBy(fn($r) => substr($r->error_date, 0, 10));
foreach ($byDate as $date => $dayRows) {
$dayOrders = $dayRows->unique('order_id');
$dayUnpaid = $dayOrders->where('paid', 0);
$lines[] = sprintf(
' %s: %3d Fehler | %3d Bestellungen | %3d nicht bezahlt | %s EUR offen',
$date,
$dayRows->count(),
$dayOrders->count(),
$dayUnpaid->count(),
number_format($dayUnpaid->sum('total_shipping'), 2, ',', '.')
);
}
$lines[] = '';
$lines[] = '--------------------------------------------------------------------------------';
$lines[] = ' NICHT BEZAHLTE BESTELLUNGEN (DETAIL)';
$lines[] = '--------------------------------------------------------------------------------';
$lines[] = '';
$lines[] = sprintf(
' %-12s %-20s %-18s %-14s %s',
'Bestell-Nr',
'Datum',
'Referenz',
'Betrag (EUR)',
'Status'
);
$lines[] = ' ' . str_repeat('-', 80);
foreach ($unpaidOrders->sortBy('order_date') as $order) {
$lines[] = sprintf(
' %-12s %-20s %-18s %14s %s',
$order->order_id,
$order->order_date,
$order->reference,
number_format($order->total_shipping, 2, ',', '.'),
$order->txaction
);
}
$lines[] = ' ' . str_repeat('-', 80);
$lines[] = sprintf(
' %-12s %-20s %-18s %14s',
'GESAMT',
'',
$unpaidOrders->count() . ' Bestellungen',
number_format($unpaidOrders->sum('total_shipping'), 2, ',', '.')
);
$lines[] = '';
$lines[] = '--------------------------------------------------------------------------------';
$lines[] = ' NACHTRÄGLICH BEZAHLTE BESTELLUNGEN (andere Zahlungsart)';
$lines[] = '--------------------------------------------------------------------------------';
$lines[] = '';
if ($paidOrders->isEmpty()) {
$lines[] = ' Keine.';
} else {
$lines[] = sprintf(
' %-12s %-20s %-18s %-14s %s',
'Bestell-Nr',
'Datum',
'Referenz',
'Betrag (EUR)',
'Status'
);
$lines[] = ' ' . str_repeat('-', 80);
foreach ($paidOrders->sortBy('order_date') as $order) {
$lines[] = sprintf(
' %-12s %-20s %-18s %14s %s',
$order->order_id,
$order->order_date,
$order->reference,
number_format($order->total_shipping, 2, ',', '.'),
$order->txaction
);
}
$lines[] = ' ' . str_repeat('-', 80);
$lines[] = sprintf(
' %-12s %-20s %-18s %14s',
'GESAMT',
'',
$paidOrders->count() . ' Bestellungen',
number_format($paidOrders->sum('total_shipping'), 2, ',', '.')
);
}
$lines[] = '';
$this->appendEmailSectionToTxt($lines, $unpaidOrders);
$lines[] = '';
$lines[] = '================================================================================';
$lines[] = ' Ende des Berichts';
$lines[] = '================================================================================';
$lines[] = '';
file_put_contents($path, implode("\n", $lines));
}
private function appendEmailSectionToTxt(array &$lines, \Illuminate\Support\Collection $unpaidOrders): void
{
$berater = $unpaidOrders->filter(fn($o) => ! empty($o->auth_user_id))->sortBy('billing_email');
$shopKunden = $unpaidOrders->filter(fn($o) => empty($o->auth_user_id))->sortBy('billing_email');
$lines[] = '--------------------------------------------------------------------------------';
$lines[] = ' BETROFFENE BERATER (mit Auth-User-ID) - nicht bezahlt';
$lines[] = '--------------------------------------------------------------------------------';
$lines[] = '';
$lines[] = sprintf(' %-12s %-8s %-30s %-30s %14s', 'Bestell-Nr', 'User-ID', 'Name', 'E-Mail', 'Betrag (EUR)');
$lines[] = ' ' . str_repeat('-', 100);
$beraterSum = 0;
foreach ($berater as $order) {
$name = trim(($order->billing_firstname ?? '') . ' ' . ($order->billing_lastname ?? ''));
$lines[] = sprintf(
' %-12s %-8s %-30s %-30s %14s',
$order->order_id,
$order->auth_user_id,
mb_substr($name, 0, 28),
mb_substr($order->billing_email ?? '-', 0, 28),
number_format($order->total_shipping, 2, ',', '.')
);
$beraterSum += $order->total_shipping;
}
$lines[] = ' ' . str_repeat('-', 100);
$lines[] = sprintf(' %-12s %-8s %-30s %-30s %14s', 'GESAMT', '', $berater->count() . ' Bestellungen', '', number_format($beraterSum, 2, ',', '.'));
$lines[] = '';
$lines[] = '--------------------------------------------------------------------------------';
$lines[] = ' BETROFFENE SHOP-KUNDEN (ohne Auth-User-ID) - nicht bezahlt';
$lines[] = '--------------------------------------------------------------------------------';
$lines[] = '';
$lines[] = sprintf(' %-12s %-30s %-30s %14s', 'Bestell-Nr', 'Name', 'E-Mail', 'Betrag (EUR)');
$lines[] = ' ' . str_repeat('-', 90);
$shopSum = 0;
foreach ($shopKunden as $order) {
$name = trim(($order->billing_firstname ?? '') . ' ' . ($order->billing_lastname ?? ''));
$lines[] = sprintf(
' %-12s %-30s %-30s %14s',
$order->order_id,
mb_substr($name, 0, 28),
mb_substr($order->billing_email ?? '-', 0, 28),
number_format($order->total_shipping, 2, ',', '.')
);
$shopSum += $order->total_shipping;
}
$lines[] = ' ' . str_repeat('-', 90);
$lines[] = sprintf(' %-12s %-30s %-30s %14s', 'GESAMT', $shopKunden->count() . ' Bestellungen', '', number_format($shopSum, 2, ',', '.'));
}
private function writeEmailLists(string $dir, \Illuminate\Support\Collection $rows): void
{
$unpaidOrders = $rows->unique('order_id')->where('paid', 0);
$berater = $unpaidOrders->filter(fn($o) => ! empty($o->auth_user_id))->sortBy('order_date');
$shopKunden = $unpaidOrders->filter(fn($o) => empty($o->auth_user_id))->sortBy('order_date');
$this->writeEmailCsv("{$dir}/emails-berater.csv", $berater, true);
$this->writeEmailCsv("{$dir}/emails-shop-kunden.csv", $shopKunden, false);
$this->newLine();
$this->table(
['Kategorie', 'Bestellungen', 'Eindeutige E-Mails', 'Summe (EUR)'],
[
[
'Berater (mit Auth-User-ID)',
$berater->count(),
$berater->pluck('billing_email')->filter()->unique()->count(),
number_format($berater->sum('total_shipping'), 2, ',', '.') . ' EUR',
],
[
'Shop-Kunden (ohne Auth-User-ID)',
$shopKunden->count(),
$shopKunden->pluck('billing_email')->filter()->unique()->count(),
number_format($shopKunden->sum('total_shipping'), 2, ',', '.') . ' EUR',
],
]
);
}
private function writeEmailCsv(string $path, \Illuminate\Support\Collection $orders, bool $includeUserId): void
{
$fp = fopen($path, 'w');
fprintf($fp, chr(0xEF) . chr(0xBB) . chr(0xBF));
$headers = ['Bestell-Nr', 'Bestell-Datum', 'Vorname', 'Nachname', 'E-Mail', 'Betrag (EUR)', 'Status'];
if ($includeUserId) {
array_splice($headers, 1, 0, 'Auth-User-ID');
}
fputcsv($fp, $headers, ';');
foreach ($orders as $order) {
$row = [
$order->order_id,
$order->order_date,
$order->billing_firstname ?? '',
$order->billing_lastname ?? '',
$order->billing_email ?? '',
number_format($order->total_shipping, 2, ',', ''),
$order->txaction,
];
if ($includeUserId) {
array_splice($row, 1, 0, $order->auth_user_id);
}
fputcsv($fp, $row, ';');
}
fputcsv($fp, [], ';');
fputcsv($fp, ['GESAMT', '', '', '', $orders->count() . ' Bestellungen', number_format($orders->sum('total_shipping'), 2, ',', '') . ' EUR'], ';');
fputcsv($fp, ['Eindeutige E-Mail-Adressen', $orders->pluck('billing_email')->filter()->unique()->count()], ';');
fclose($fp);
}
}

View file

@ -0,0 +1,220 @@
<?php
namespace App\Console\Commands;
use App\Models\ShoppingOrder;
use App\Models\ShoppingPayment;
use App\Services\AboHelper;
use App\Services\Incentive\IncentiveTracker;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class RepairMissingAboFromOrders extends Command
{
protected $signature = 'abo:repair-missing
{--fix : Reparatur ausfuehren (ohne: nur Abgleich/Vorschau)}
{--force : Mit --fix: ohne Rueckfrage (Skripte/CI)}
{--since= : Nur Bestellungen mit created_at >= (Y-m-d)}
{--until= : Nur Bestellungen mit created_at <= Ende dieses Tages (Y-m-d)}
{--order= : Komma-getrennte shopping_order IDs (Filter)}
{--mode=live : Modus: live, test, dev oder all}
{--stats : Zusaetzliche Statistik: bezahlte Abo-Bestellungen vs. mit/ohne UserAboOrder}';
protected $description = 'Abgleich und Reparatur: bezahlte Abo-Bestellungen (Checkout) ohne Verknuepfung user_abo_orders — z. B. nach Payone-Callback vor Erfolgs-Redirect';
public function handle(): int
{
$missing = $this->queryMissingOrders()->orderBy('id')->get();
$this->info('Abgleich: Bestellungen mit is_abo, abo_interval>0, als bezahlt markiert, ohne user_abo_orders-Eintrag.');
$this->newLine();
if ($this->option('stats')) {
$this->printStats();
$this->newLine();
}
$this->info('Treffer (fehlende Verknuepfung): '.$missing->count());
if ($missing->isEmpty()) {
$this->info('Keine Diskrepanz — nichts zu tun.');
return self::SUCCESS;
}
$this->table(
['ID', 'shopping_user_id', 'mode', 'txaction', 'paid', 'created_at'],
$missing->take(200)->map(fn (ShoppingOrder $o) => [
$o->id,
$o->shopping_user_id,
$o->mode,
$o->txaction,
$o->paid ? '1' : '0',
$o->created_at?->format('Y-m-d H:i'),
])
);
if ($missing->count() > 200) {
$this->warn('… und weitere '.($missing->count() - 200).' Eintraege (Ausgabe gekuerzt).');
}
if (! $this->option('fix')) {
$this->newLine();
$this->warn('Trockenlauf. Nutze --fix zur Reparatur (mit Bestaetigung).');
return self::SUCCESS;
}
if (! $this->option('force') && ! $this->confirm('Wirklich '.$missing->count().' Bestellung(en) reparieren?')) {
return self::SUCCESS;
}
$ok = 0;
$fail = 0;
$bar = $this->output->createProgressBar($missing->count());
$bar->start();
foreach ($missing as $order) {
try {
DB::transaction(function () use ($order) {
$this->repairSingleOrder($order);
});
$ok++;
} catch (\Throwable $e) {
$fail++;
$this->newLine();
$this->error("Order #{$order->id}: {$e->getMessage()}");
}
$bar->advance();
}
$bar->finish();
$this->newLine(2);
$this->info("Fertig: {$ok} repariert, {$fail} Fehler.");
return $fail > 0 ? self::FAILURE : self::SUCCESS;
}
/**
* @return \Illuminate\Database\Eloquent\Builder<ShoppingOrder>
*/
private function queryMissingOrders(): \Illuminate\Database\Eloquent\Builder
{
$q = ShoppingOrder::query()
->where('is_abo', true)
->where('abo_interval', '>', 0)
->where(function ($sub) {
$sub->where('paid', true)
->orWhere('paid', 1);
})
->whereIn('txaction', ['paid', 'invoice_paid', 'extern_paid'])
->whereNotNull('shopping_user_id')
->whereHas('shopping_payments')
->whereNotExists(function ($sub) {
$sub->select(DB::raw('1'))
->from('user_abo_orders')
->whereColumn('user_abo_orders.shopping_order_id', 'shopping_orders.id');
});
if ($ids = $this->parseOrderIds()) {
$q->whereIn('id', $ids);
}
if ($since = $this->option('since')) {
$q->where('created_at', '>=', $since.' 00:00:00');
}
if ($until = $this->option('until')) {
$q->where('created_at', '<=', $until.' 23:59:59');
}
$mode = (string) $this->option('mode');
if ($mode !== 'all') {
$q->where('mode', $mode);
}
return $q;
}
/**
* @return list<int>
*/
private function parseOrderIds(): array
{
$raw = $this->option('order');
if ($raw === null || $raw === '') {
return [];
}
return array_values(array_filter(array_map('intval', explode(',', (string) $raw))));
}
private function printStats(): void
{
$mode = (string) $this->option('mode');
$base = ShoppingOrder::query()
->where('is_abo', true)
->where('abo_interval', '>', 0)
->where(function ($sub) {
$sub->where('paid', true)->orWhere('paid', 1);
})
->whereIn('txaction', ['paid', 'invoice_paid', 'extern_paid']);
if ($since = $this->option('since')) {
$base->where('created_at', '>=', $since.' 00:00:00');
}
if ($until = $this->option('until')) {
$base->where('created_at', '<=', $until.' 23:59:59');
}
if ($mode !== 'all') {
$base->where('mode', $mode);
}
if ($ids = $this->parseOrderIds()) {
$base->whereIn('id', $ids);
}
$totalPaidAbo = (clone $base)->count();
$withLink = (clone $base)->whereExists(function ($sub) {
$sub->select(DB::raw('1'))
->from('user_abo_orders')
->whereColumn('user_abo_orders.shopping_order_id', 'shopping_orders.id');
})->count();
$this->table(
['Kennzahl', 'Anzahl'],
[
['Bezahlte Abo-Bestellungen (Filter)', $totalPaidAbo],
['Davon mit user_abo_orders', $withLink],
['Davon ohne user_abo_orders', max(0, $totalPaidAbo - $withLink)],
]
);
}
private function repairSingleOrder(ShoppingOrder $order): void
{
$payment = ShoppingPayment::query()
->where('shopping_order_id', $order->id)
->orderByDesc('id')
->first();
if (! $payment) {
throw new \RuntimeException('Kein ShoppingPayment zur Bestellung.');
}
$order->loadMissing(['shopping_user', 'shopping_order_items']);
$payment->loadMissing(['payment_transactions']);
$payment->setRelation('shopping_order', $order);
AboHelper::createNewAbo($payment);
$order->refresh();
if (! $order->getUserAbo()) {
throw new \RuntimeException('createNewAbo hat kein UserAbo erzeugt (pruefen: abo_interval, Bestellpositionen, ShoppingPayment.abo_interval).');
}
AboHelper::setAboActive($order, 2, true);
IncentiveTracker::trackAboActivated($order);
}
}

View file

@ -0,0 +1,129 @@
<?php
namespace App\Console\Commands;
use App\Models\ShoppingOrder;
use App\Repositories\InvoiceRepository;
use App\Services\BusinessPlan\SalesPointsVolume;
use App\Services\Incentive\IncentiveTracker;
use Illuminate\Console\Command;
class RepairMissingInvoices extends Command
{
protected $signature = 'repair:missing-invoices
{--fix : Tatsaechlich reparieren (ohne Flag nur Vorschau)}
{--no-mail : Keine Rechnungs-Mails versenden}
{--since=2026-02-20 : Ab welchem Datum suchen}';
protected $description = 'Repariert fehlende Rechnungen und SalesVolumes fuer bezahlte Bestellungen (Bug: addSalesPointsVolumeUser)';
public function handle(): int
{
$since = $this->option('since') ?? '2026-03-16';
$fix = $this->option('fix') ?? false;
$orders = ShoppingOrder::query()
->where('mode', 'live')
->where('paid', 0)
->where('txaction', 'paid')
->where('created_at', '>=', $since)
->whereNull('deleted_at')
->whereDoesntHave('user_invoice')
->whereDoesntHave('user_sales_volume')
// ->whereDoesntHave('shopping_payments', fn($q) => $q->where('clearingtype', 'vor'))
->orderBy('created_at')
->get();
$this->info("Betroffene Bestellungen seit {$since}: {$orders->count()}");
if ($orders->isEmpty()) {
$this->info('Keine betroffenen Bestellungen gefunden.');
return self::SUCCESS;
}
// Zusammenfassung
$total = $orders->sum('total');
$byPaymentFor = $orders->groupBy('payment_for')->map->count();
$this->table(
['payment_for', 'Anzahl'],
$byPaymentFor->map(fn ($count, $type) => [$type, $count])->values()
);
$this->info("Gesamtwert: {$total} EUR");
if (! $fix) {
$this->warn('Trockenlauf! Nutze --fix um die Reparatur durchzufuehren.');
$this->newLine();
// Erste 10 anzeigen
$this->table(
['ID', 'payment_for', 'total', 'txaction', 'created_at'],
$orders->take(100)->map(fn ($o) => [
$o->id,
$o->payment_for,
$o->total,
$o->txaction,
$o->created_at->format('Y-m-d H:i'),
])
);
if ($orders->count() > 100) {
$this->info('... und '.($orders->count() - 100).' weitere');
}
return self::SUCCESS;
}
$send_mail = ! $this->option('no-mail');
if ($send_mail) {
$this->info('Rechnungs-Mails werden versendet. Nutze --no-mail um dies zu unterdruecken.');
} else {
$this->warn('Rechnungs-Mails werden NICHT versendet.');
}
if (! $this->confirm("Wirklich {$orders->count()} Bestellungen reparieren?")) {
return self::SUCCESS;
}
$success = 0;
$errors = 0;
$bar = $this->output->createProgressBar($orders->count());
$bar->start();
foreach ($orders as $order) {
try {
// 1. SalesVolume erstellen
$user_sales_volume = SalesPointsVolume::User($order);
// 2. Rechnung erstellen (mit Mail-Versand)
$invoice_repo = new InvoiceRepository($order);
$user_invoice = $invoice_repo->create([
'invoice_send_mail' => $send_mail,
]);
// 3. SalesVolume mit Rechnung verknuepfen
$user_sales_volume->user_invoice_id = $user_invoice->id;
$user_sales_volume->save();
// 4. Incentive tracking (falls relevant)
IncentiveTracker::trackSalesVolume($user_sales_volume);
$success++;
$this->info("Order #{$order->id}: Reparatur erfolgreich");
} catch (\Throwable $e) {
$errors++;
$this->newLine();
$this->error("Order #{$order->id}: {$e->getMessage()}");
}
$bar->advance();
}
$bar->finish();
$this->newLine(2);
$this->info("Fertig: {$success} repariert, {$errors} Fehler.");
return $errors > 0 ? self::FAILURE : self::SUCCESS;
}
}

View file

@ -0,0 +1,297 @@
<?php
namespace App\Console\Commands;
use App\Cron\UserMakeOrder;
use App\Models\UserAbo;
use App\Models\UserAboOrder;
use App\Services\AboHelper;
use App\Services\Incentive\IncentiveTracker;
use App\Services\MyLog;
use App\Services\Payment;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class RetryFailedPaypalAbos extends Command
{
protected $signature = 'abo:retry-failed-paypal
{--dry-run : Nur anzeigen, keine Bestellungen ausführen}
{--abo-id= : Nur ein bestimmtes Abo erneut ausführen}';
protected $description = 'Führt Abo-Bestellungen erneut aus, die aufgrund der PayPal-Panne (Error 923) fehlgeschlagen sind';
private float $timeStart;
public function handle(): int
{
$this->timeStart = microtime(true);
$dryRun = $this->option('dry-run');
$singleAboId = $this->option('abo-id');
\Log::channel('abo_order')->info('RetryFailedPaypalAbos: Gestartet', [
'dry_run' => $dryRun,
'abo_id' => $singleAboId,
]);
$this->info($dryRun ? '=== DRY-RUN Modus (keine Bestellungen) ===' : '=== LIVE Modus ===');
$this->newLine();
$abos = $this->getAffectedAbos($singleAboId);
if ($abos->isEmpty()) {
$this->warn('Keine betroffenen PayPal-Abos gefunden.');
return self::SUCCESS;
}
$this->displayAboList($abos);
if (! $dryRun && ! $singleAboId) {
if (! $this->confirm("Sollen alle {$abos->count()} Abos jetzt erneut ausgeführt werden?")) {
$this->info('Abgebrochen.');
return self::SUCCESS;
}
}
$results = ['success' => 0, 'error' => 0, 'skipped' => 0];
foreach ($abos as $userAbo) {
if ($dryRun) {
$this->info(" [DRY-RUN] Abo #{$userAbo->id} würde ausgeführt werden");
$results['skipped']++;
continue;
}
try {
$result = $this->retryAboOrder($userAbo);
if ($result) {
$results['success']++;
} else {
$results['error']++;
}
} catch (\Throwable $e) {
$results['error']++;
\Log::channel('abo_order')->error('RetryFailedPaypalAbos: Exception', [
'abo_id' => $userAbo->id,
'error' => $e->getMessage(),
]);
$this->error(" Abo #{$userAbo->id}: Exception - {$e->getMessage()}");
}
}
$this->newLine();
$this->table(
['Ergebnis', 'Anzahl'],
[
['Erfolgreich', $results['success']],
['Fehlgeschlagen', $results['error']],
['Übersprungen (Dry-Run)', $results['skipped']],
]
);
$executionTime = $this->getExecutionTime();
$this->info("Abgeschlossen in {$executionTime}");
\Log::channel('abo_order')->info("RetryFailedPaypalAbos: Abgeschlossen in {$executionTime}", $results);
return self::SUCCESS;
}
/**
* @return \Illuminate\Database\Eloquent\Collection<int, UserAbo>
*/
private function getAffectedAbos(?string $singleAboId): \Illuminate\Database\Eloquent\Collection
{
$query = UserAbo::query()
->where('status', 3)
->where('active', true)
->where('clearingtype', 'wlt')
->where('wallettype', 'PPE')
->whereRaw("DATE(next_date) = '2026-04-05'")
->with(['shopping_user', 'user_abo_items']);
if ($singleAboId) {
$query->where('id', $singleAboId);
}
return $query->orderBy('id')->get();
}
private function displayAboList(\Illuminate\Database\Eloquent\Collection $abos): void
{
$rows = $abos->map(fn (UserAbo $abo) => [
$abo->id,
$abo->user_id ?? '-',
$abo->is_for,
$abo->email,
$abo->abo_interval,
$abo->getRawOriginal('next_date'),
$abo->user_abo_items->count().' Artikel',
]);
$this->table(
['Abo-ID', 'User-ID', 'Typ', 'E-Mail', 'Intervall', 'Next-Date', 'Artikel'],
$rows->toArray()
);
$this->info("Betroffene Abos: {$abos->count()}");
$this->newLine();
}
private function retryAboOrder(UserAbo $userAbo): bool
{
$this->info(" Verarbeite Abo #{$userAbo->id} ({$userAbo->email})...");
\Log::channel('abo_order')->info('RetryFailedPaypalAbos: Verarbeite Abo', [
'abo_id' => $userAbo->id,
'email' => $userAbo->email,
'payone_userid' => $userAbo->payone_userid,
]);
$alreadyPaidToday = UserAboOrder::where('user_abo_id', $userAbo->id)
->whereDate('created_at', now()->toDateString())
->where('paid', true)
->exists();
if ($alreadyPaidToday) {
$this->warn(" Abo #{$userAbo->id}: Bereits heute bezahlt - übersprungen");
return true;
}
AboHelper::ensureUserAboItemsFromLatestOrder($userAbo);
$shoppingOrder = null;
$userOrder = new UserMakeOrder($userAbo);
try {
if (! $userOrder->createShoppingUser()) {
$this->error(" Abo #{$userAbo->id}: Shopping-User konnte nicht erstellt werden");
return false;
}
$shoppingOrder = $userOrder->makeShoppingOrder();
if (! $shoppingOrder) {
$this->error(" Abo #{$userAbo->id}: Bestellung konnte nicht erstellt werden");
return false;
}
$this->info(" Bestellung #{$shoppingOrder->id} erstellt (Betrag: {$shoppingOrder->total_shipping} EUR)");
$response = $userOrder->makePayment();
if (is_object($response)) {
$response = (array) $response;
}
if (! isset($response['status'])) {
$this->error(" Abo #{$userAbo->id}: Ungültige Zahlungsantwort");
$this->markAboError($userAbo, $shoppingOrder);
return false;
}
if ($response['status'] === 'APPROVED') {
$this->info(" Zahlung ERFOLGREICH für Abo #{$userAbo->id}");
$this->markAboSuccess($userAbo, $shoppingOrder);
return true;
}
$errorCode = $response['errorcode'] ?? '-';
$errorMsg = $response['errormessage'] ?? '-';
$this->error(" Zahlung FEHLGESCHLAGEN für Abo #{$userAbo->id}: [{$errorCode}] {$errorMsg}");
MyLog::writeLog(
'userabo',
'error',
'Error:RetryPaypal RetryFailedPaypalAbos / makePayment Error',
$response
);
$this->markAboError($userAbo, $shoppingOrder);
$shoppingPayment = $userOrder->getShoppingPayment();
if ($shoppingPayment) {
Payment::paymentStatusSendMail($shoppingOrder, $shoppingPayment, [
'mode' => $shoppingPayment->mode,
'txaction' => 'error',
'send_link' => false,
'payment_error' => $response,
]);
}
return false;
} catch (\Throwable $e) {
\Log::channel('abo_order')->error('RetryFailedPaypalAbos: Exception bei Abo-Verarbeitung', [
'abo_id' => $userAbo->id,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
$this->error(" Exception: {$e->getMessage()}");
if ($shoppingOrder) {
$this->markAboError($userAbo, $shoppingOrder);
}
return false;
}
}
private function markAboSuccess(UserAbo $userAbo, $shoppingOrder): void
{
DB::transaction(function () use ($userAbo, $shoppingOrder) {
$nextDate = AboHelper::setNextDate(now(), $userAbo->abo_interval);
$userAbo->update([
'status' => 2,
'next_date' => $nextDate,
'last_date' => now(),
]);
UserAboOrder::create([
'user_abo_id' => $userAbo->id,
'shopping_order_id' => $shoppingOrder->id,
'status' => 1,
'paid' => true,
]);
});
IncentiveTracker::trackAboActivated($shoppingOrder);
$nextDateFormatted = Carbon::parse($userAbo->getRawOriginal('next_date'))->format('d.m.Y');
$this->info(" Status → 2 (abo_okay), nächstes Datum → {$nextDateFormatted}");
\Log::channel('abo_order')->info('RetryFailedPaypalAbos: Abo erfolgreich reaktiviert', [
'abo_id' => $userAbo->id,
'order_id' => $shoppingOrder->id,
'next_date' => $userAbo->getRawOriginal('next_date'),
]);
}
private function markAboError(UserAbo $userAbo, $shoppingOrder): void
{
DB::transaction(function () use ($userAbo, $shoppingOrder) {
$userAbo->update(['last_date' => now()]);
UserAboOrder::create([
'user_abo_id' => $userAbo->id,
'shopping_order_id' => $shoppingOrder->id,
'status' => 3,
'paid' => false,
]);
});
}
private function getExecutionTime(): string
{
$diff = microtime(true) - $this->timeStart;
$sec = intval($diff);
$micro = $diff - $sec;
return $sec.' Sekunden und '.round($micro * 1000, 2).' ms';
}
}

View file

@ -2,13 +2,12 @@
namespace App\Console\Commands;
use Carbon\Carbon;
use App\Models\UserAbo;
use App\Cron\UserMakeOrder;
use App\Services\AboHelper;
use App\Models\UserAbo;
use App\Models\UserAboOrder;
use App\Services\AboHelper;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\DB;
class TestUserMakeAboOrder extends Command
@ -63,8 +62,9 @@ class TestUserMakeAboOrder extends Command
if ($aboId) {
// Test für spezifisches Abo
$userAbo = UserAbo::find($aboId);
if (!$userAbo) {
if (! $userAbo) {
$this->error("Abo mit ID {$aboId} nicht gefunden!");
return 1;
}
@ -80,8 +80,9 @@ class TestUserMakeAboOrder extends Command
return 0;
} catch (\Exception $e) {
$this->error('Fehler beim Testen: ' . $e->getMessage());
$this->error('Fehler beim Testen: '.$e->getMessage());
$this->error($e->getTraceAsString());
return 1;
}
}
@ -89,10 +90,10 @@ class TestUserMakeAboOrder extends Command
/**
* Testet ein einzelnes Abo
*
* @param UserAbo $userAbo
* @param string $testDate
* @param bool $dryRun
* @param bool $force
* @param UserAbo $userAbo
* @param string $testDate
* @param bool $dryRun
* @param bool $force
* @return void
*/
private function testSingleAbo($userAbo, $testDate, $dryRun, $force)
@ -101,15 +102,15 @@ class TestUserMakeAboOrder extends Command
$this->displayAboInfo($userAbo);
// Prüfe ob Abo für Test-Datum fällig ist
if ($userAbo->next_date != $testDate && !$force) {
if ($userAbo->next_date != $testDate && ! $force) {
$this->warn("Abo ist nicht für {$testDate} fällig (next_date: {$userAbo->next_date})");
if (!$this->confirm('Trotzdem fortfahren?', false)) {
if (! $this->confirm('Trotzdem fortfahren?', false)) {
return;
}
}
// Prüfe auf Duplikate
if (!$force) {
if (! $force) {
$existingOrder = UserAboOrder::where('user_abo_id', $userAbo->id)
->whereDate('created_at', $testDate)
->first();
@ -117,7 +118,7 @@ class TestUserMakeAboOrder extends Command
if ($existingOrder) {
$this->warn("Es existiert bereits eine Bestellung für dieses Abo am {$testDate}");
$this->info("Bestell-ID: {$existingOrder->shopping_order_id}");
if (!$this->confirm('Trotzdem fortfahren?', false)) {
if (! $this->confirm('Trotzdem fortfahren?', false)) {
return;
}
}
@ -144,7 +145,7 @@ class TestUserMakeAboOrder extends Command
if ($shoppingOrder) {
$this->info("✓ Bestellung erfolgreich erstellt: ID {$shoppingOrder->id}");
} else {
$this->error("✗ Bestellung konnte nicht erstellt werden");
$this->error('✗ Bestellung konnte nicht erstellt werden');
}
} finally {
// next_date zurücksetzen falls geändert
@ -160,9 +161,9 @@ class TestUserMakeAboOrder extends Command
/**
* Testet alle fälligen Abos
*
* @param string $testDate
* @param bool $dryRun
* @param bool $force
* @param string $testDate
* @param bool $dryRun
* @param bool $force
* @return void
*/
private function testAllAbos($testDate, $dryRun, $force)
@ -170,7 +171,7 @@ class TestUserMakeAboOrder extends Command
$query = UserAbo::where('next_date', '=', $testDate)
->where('active', true);
if (!$force) {
if (! $force) {
$query->whereDoesntHave('user_abo_orders', function ($q) use ($testDate) {
$q->whereDate('created_at', $testDate);
});
@ -184,10 +185,11 @@ class TestUserMakeAboOrder extends Command
if ($count === 0) {
$this->warn('Keine fälligen Abos gefunden!');
return;
}
if (!$this->confirm("Möchten Sie {$count} Abo(s) testen?", true)) {
if (! $this->confirm("Möchten Sie {$count} Abo(s) testen?", true)) {
return;
}
@ -203,7 +205,7 @@ class TestUserMakeAboOrder extends Command
/**
* Zeigt Informationen über ein Abo an
*
* @param UserAbo $userAbo
* @param UserAbo $userAbo
* @return void
*/
private function displayAboInfo($userAbo)
@ -215,11 +217,11 @@ class TestUserMakeAboOrder extends Command
['User ID', $userAbo->user_id],
['Payone UserID', $userAbo->payone_userid],
['Aktiv', $userAbo->active ? 'Ja' : 'Nein'],
['Status', $userAbo->status . ' (' . ($userAbo->getStatusType() ?? 'unbekannt') . ')'],
['Status', $userAbo->status.' ('.($userAbo->getStatusType() ?? 'unbekannt').')'],
['Intervall', $userAbo->abo_interval],
['Next Date', $userAbo->next_date],
['Last Date', $userAbo->last_date ?? 'Nie'],
['Amount', number_format($userAbo->amount / 100, 2, ',', '.') . ' €'],
['Amount', number_format($userAbo->amount / 100, 2, ',', '.').' €'],
['is_for', $userAbo->is_for],
['Clearing Type', $userAbo->clearingtype],
['Items', $userAbo->user_abo_items->count()],
@ -235,7 +237,7 @@ class TestUserMakeAboOrder extends Command
'Product ID' => $item->product_id,
'Qty' => $item->qty,
'Comp' => $item->comp ?? '-',
'Price' => number_format($item->price / 100, 2, ',', '.') . ' €',
'Price' => number_format($item->price / 100, 2, ',', '.').' €',
];
}
$this->table(['Product ID', 'Qty', 'Comp', 'Price'], $items);
@ -245,7 +247,7 @@ class TestUserMakeAboOrder extends Command
/**
* Zeigt eine Vorschau der Bestellung an
*
* @param UserAbo $userAbo
* @param UserAbo $userAbo
* @return void
*/
private function displayOrderPreview($userAbo)
@ -265,8 +267,8 @@ class TestUserMakeAboOrder extends Command
/**
* Erstellt eine Bestellung für ein Abo (vereinfachte Version für Test)
*
* @param UserAbo $userAbo
* @param bool $dryRun
* @param UserAbo $userAbo
* @param bool $dryRun
* @return mixed
*/
private function makeOrder($userAbo, $dryRun = false)
@ -274,18 +276,20 @@ class TestUserMakeAboOrder extends Command
$this->info('Erstelle Shopping-User...');
$userOrder = new UserMakeOrder($userAbo);
if (!$userOrder->createShoppingUser()) {
if (! $userOrder->createShoppingUser()) {
$this->error('Konnte Shopping-User nicht erstellen');
return null;
}
$this->info('✓ Shopping-User erstellt');
$this->info('Erstelle Bestellung...');
$shoppingOrder = $userOrder->makeShoppingOrder();
$shoppingOrder->mode = 'test'; //immer im test mode testen
$shoppingOrder->mode = 'test'; // immer im test mode testen
$shoppingOrder->save();
if (!$shoppingOrder) {
if (! $shoppingOrder) {
$this->error('Konnte Bestellung nicht erstellen');
return null;
}
$this->info("✓ Bestellung erstellt: ID {$shoppingOrder->id}");
@ -293,6 +297,7 @@ class TestUserMakeAboOrder extends Command
if ($dryRun) {
$this->info('[DRY-RUN] Zahlung würde durchgeführt');
$this->info('[DRY-RUN] Abo würde aktualisiert');
return $shoppingOrder;
}
@ -304,10 +309,11 @@ class TestUserMakeAboOrder extends Command
$response = (array) $response;
}
$this->info('Zahlungsantwort: ' . json_encode($response, JSON_PRETTY_PRINT));
$this->info('Zahlungsantwort: '.json_encode($response, JSON_PRETTY_PRINT));
if (!isset($response['status'])) {
if (! isset($response['status'])) {
$this->warn('⚠ Kein Status in Zahlungsantwort');
return $shoppingOrder;
}
@ -323,7 +329,7 @@ class TestUserMakeAboOrder extends Command
$this->warn("⚠ Zahlungsstatus: {$response['status']}");
}
} catch (\Exception $e) {
$this->error('Fehler bei Zahlung: ' . $e->getMessage());
$this->error('Fehler bei Zahlung: '.$e->getMessage());
}
return $shoppingOrder;
@ -332,9 +338,9 @@ class TestUserMakeAboOrder extends Command
/**
* Aktualisiert das Abo nach erfolgreicher Bestellung (vereinfachte Version)
*
* @param UserAbo $userAbo
* @param mixed $shoppingOrder
* @param int $status
* @param UserAbo $userAbo
* @param mixed $shoppingOrder
* @param int $status
* @return void
*/
private function updateAbo($userAbo, $shoppingOrder, $status = 1)
@ -356,10 +362,11 @@ class TestUserMakeAboOrder extends Command
'user_abo_id' => $userAbo->id,
'shopping_order_id' => $shoppingOrder->id,
'status' => $status,
'paid' => true,
]);
});
} catch (\Exception $e) {
$this->error('Fehler beim Aktualisieren des Abos: ' . $e->getMessage());
$this->error('Fehler beim Aktualisieren des Abos: '.$e->getMessage());
throw $e;
}
}
@ -375,6 +382,6 @@ class TestUserMakeAboOrder extends Command
$sec = intval($diff);
$micro = $diff - $sec;
return $sec . ' Sekunden und ' . round($micro * 1000, 2) . ' ms';
return $sec.' Sekunden und '.round($micro * 1000, 2).' ms';
}
}

View file

@ -2,16 +2,15 @@
namespace App\Console\Commands;
use Carbon\Carbon;
use App\Models\Setting;
use App\Cron\UserMakeOrder;
use App\Models\UserAbo;
use App\Models\UserAboOrder;
use App\Services\AboHelper;
use App\Services\Incentive\IncentiveTracker;
use App\Services\MyLog;
use App\Services\Payment;
use App\Cron\UserMakeOrder;
use App\Services\AboHelper;
use App\Models\UserAboOrder;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\DB;
class UserMakeAboOrder extends Command
@ -33,10 +32,13 @@ class UserMakeAboOrder extends Command
protected $description = 'Make Orders from Abos';
private $timeStart;
private $month;
private $year;
private $sendCreditMail = false;
private $sendUpdateMail = false;
/**
@ -70,9 +72,10 @@ class UserMakeAboOrder extends Command
} catch (\Exception $e) {
\Log::channel('cron')->error('UserMakeAboOrder: Fehler beim Ausführen des Befehls', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
'trace' => $e->getTraceAsString(),
]);
$this->error('Fehler beim Ausführen des Befehls: ' . $e->getMessage());
return 1;
}
}
@ -91,7 +94,7 @@ class UserMakeAboOrder extends Command
// Prüfe auf bereits verarbeitete Abos am heutigen Tag (Duplikatsprüfung)
$userAbos = UserAbo::where('next_date', '=', $dateNow)
->where('active', true)
->where('status', '=', 2) //abo_okay
->where('status', '=', 2) // abo_okay
->whereDoesntHave('user_abo_orders', function ($query) use ($dateNow) {
$query->whereDate('created_at', $dateNow);
})
@ -104,7 +107,7 @@ class UserMakeAboOrder extends Command
foreach ($userAbos as $userAbo) {
\Log::channel('abo_order')->info('UserMakeAboOrder: Verarbeite Abo', [
'abo_id' => $userAbo->id,
'payone_userid' => $userAbo->payone_userid
'payone_userid' => $userAbo->payone_userid,
]);
$this->info("Verarbeite Abo: {$userAbo->id} (PayoneUserid: {$userAbo->payone_userid})");
@ -116,14 +119,15 @@ class UserMakeAboOrder extends Command
$lockedAbo = UserAbo::where('id', $userAbo->id)
->where('next_date', '=', $dateNow)
->where('active', true)
->where('status', '=', 2) //abo_okay
->where('status', '=', 2) // abo_okay
->lockForUpdate()
->first();
if (!$lockedAbo) {
if (! $lockedAbo) {
\Log::channel('abo_order')->warning('UserMakeAboOrder: Abo wurde bereits verarbeitet oder ist nicht mehr aktiv', [
'abo_id' => $userAbo->id
'abo_id' => $userAbo->id,
]);
return null;
}
@ -135,8 +139,9 @@ class UserMakeAboOrder extends Command
if ($existingOrder) {
\Log::channel('abo_order')->info('UserMakeAboOrder: Abo wurde bereits heute verarbeitet', [
'abo_id' => $lockedAbo->id,
'existing_order_id' => $existingOrder->shopping_order_id
'existing_order_id' => $existingOrder->shopping_order_id,
]);
return null;
}
@ -146,18 +151,18 @@ class UserMakeAboOrder extends Command
if ($shoppingOrder) {
\Log::channel('abo_order')->info('UserMakeAboOrder: Bestellung erstellt', [
'abo_id' => $userAbo->id,
'order_id' => $shoppingOrder->id
'order_id' => $shoppingOrder->id,
]);
$this->info("Bestellung erstellt: {$shoppingOrder->id}");
} else {
\Log::channel('abo_order')->warning('UserMakeAboOrder: Keine Bestellung erstellt für Abo', ['abo_id' => $userAbo->id]);
$this->warn("Keine Bestellung erstellt für Abo: {$userAbo->id}");
}
} catch (\Exception $e) {
} catch (\Throwable $e) {
\Log::channel('abo_order')->error('UserMakeAboOrder: Fehler bei der Verarbeitung des Abos', [
'abo_id' => $userAbo->id,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
'trace' => $e->getTraceAsString(),
]);
$this->error("Fehler bei Abo {$userAbo->id}: " . $e->getMessage());
}
@ -167,7 +172,7 @@ class UserMakeAboOrder extends Command
/**
* Erstellt eine Bestellung für ein Abo
*
* @param UserAbo $userAbo
* @param UserAbo $userAbo
* @return mixed
*/
private function makeOrder($userAbo)
@ -179,22 +184,24 @@ class UserMakeAboOrder extends Command
$userOrder = new UserMakeOrder($userAbo);
try {
if (!$userOrder->createShoppingUser()) {
if (! $userOrder->createShoppingUser()) {
\Log::channel('abo_order')->error('UserMakeAboOrder: Konnte Shopping-User nicht erstellen', ['abo_id' => $userAbo->id]);
$this->error("Konnte Shopping-User für Abo {$userAbo->id} nicht erstellen");
return null;
}
$shoppingOrder = $userOrder->makeShoppingOrder();
if (!$shoppingOrder) {
if (! $shoppingOrder) {
\Log::channel('abo_order')->error('UserMakeAboOrder: Konnte Bestellung nicht erstellen', ['abo_id' => $userAbo->id]);
$this->error("Konnte Bestellung für Abo {$userAbo->id} nicht erstellen");
return null;
}
\Log::channel('abo_order')->info('UserMakeAboOrder: Bestellung erstellt, starte Zahlungsvorgang', [
'abo_id' => $userAbo->id,
'order_id' => $shoppingOrder->id
'order_id' => $shoppingOrder->id,
]);
$response = $userOrder->makePayment();
@ -205,17 +212,18 @@ class UserMakeAboOrder extends Command
$response = (array) $response;
}
if (!isset($response['status'])) {
if (! isset($response['status'])) {
\Log::channel('abo_order')->error('UserMakeAboOrder: Ungültige Zahlungsantwort', [
'abo_id' => $userAbo->id,
'order_id' => $shoppingOrder->id,
'response' => $response
'response' => $response,
]);
$this->error("Ungültige Zahlungsantwort für Abo {$userAbo->id}");
// Bei fehlender Status-Information: Abo nicht aktualisieren, damit es beim nächsten Lauf erneut versucht wird
// Aber Bestellung speichern für Nachverfolgung
$this->updateAboOnError($userAbo, $shoppingOrder, 'Ungültige Zahlungsantwort - kein Status');
return $shoppingOrder;
}
@ -223,7 +231,7 @@ class UserMakeAboOrder extends Command
\Log::channel('abo_order')->info('UserMakeAboOrder: Zahlung erfolgreich', [
'abo_id' => $userAbo->id,
'order_id' => $shoppingOrder->id,
'response' => $response
'response' => $response,
]);
$this->info("Zahlung erfolgreich für Abo {$userAbo->id}");
// Nur bei erfolgreicher Zahlung: next_date aktualisieren
@ -232,7 +240,7 @@ class UserMakeAboOrder extends Command
\Log::channel('abo_order')->error('UserMakeAboOrder: Zahlungsfehler', [
'abo_id' => $userAbo->id,
'order_id' => $shoppingOrder->id,
'error' => $response
'error' => $response,
]);
$this->error("Zahlungsfehler für Abo {$userAbo->id}");
@ -263,7 +271,7 @@ class UserMakeAboOrder extends Command
\Log::channel('abo_order')->info('UserMakeAboOrder: Zahlung ausstehend/weiterleitung', [
'abo_id' => $userAbo->id,
'order_id' => $shoppingOrder->id,
'status' => $response['status']
'status' => $response['status'],
]);
$this->info("Zahlung ausstehend für Abo {$userAbo->id}: {$response['status']}");
$this->updateAboOnError($userAbo, $shoppingOrder, 'Zahlung ausstehend: ' . $response['status']);
@ -272,23 +280,29 @@ class UserMakeAboOrder extends Command
\Log::channel('abo_order')->warning('UserMakeAboOrder: Unbekannter Zahlungsstatus', [
'abo_id' => $userAbo->id,
'order_id' => $shoppingOrder->id,
'status' => $response['status']
'status' => $response['status'],
]);
$this->warn("Unbekannter Zahlungsstatus für Abo {$userAbo->id}: {$response['status']}");
$this->updateAboOnError($userAbo, $shoppingOrder, 'Unbekannter Status: ' . $response['status']);
}
} catch (\Exception $e) {
} catch (\Throwable $e) {
\Log::channel('abo_order')->error('UserMakeAboOrder: Ausnahme bei der Bestellungserstellung', [
'abo_id' => $userAbo->id,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
'trace' => $e->getTraceAsString(),
]);
$this->error("Ausnahme bei Abo {$userAbo->id}: " . $e->getMessage());
// Bei Exception: Bestellung speichern falls vorhanden, aber Abo nicht aktualisieren
// Bestellung existiert (z. B. Fehler bei Payone): Abo-Fehlerstatus, Bestellung bleibt nachvollziehbar
if ($shoppingOrder) {
$this->updateAboOnError($userAbo, $shoppingOrder, 'Exception: ' . $e->getMessage());
return $shoppingOrder;
}
// Noch keine ShoppingOrder (createShoppingUser / makeShoppingOrder): Exception durchreichen,
// sonst ruft der Aufrufer nur "null" ohne Ursache (z. B. Testbench, fehlende country_id im Yard).
throw $e;
}
return $shoppingOrder;
@ -298,9 +312,9 @@ class UserMakeAboOrder extends Command
* Aktualisiert das Abo nach einer erfolgreichen Bestellung
* Aktualisiert next_date für den nächsten Abo-Zyklus
*
* @param UserAbo $userAbo
* @param mixed $shoppingOrder
* @param int $status
* @param UserAbo $userAbo
* @param mixed $shoppingOrder
* @param int $status
* @return void
*/
private function updateAbo($userAbo, $shoppingOrder, $status = 1)
@ -308,7 +322,7 @@ class UserMakeAboOrder extends Command
\Log::channel('abo_order')->info('UserMakeAboOrder: Aktualisiere Abo nach erfolgreicher Zahlung', [
'abo_id' => $userAbo->id,
'order_id' => $shoppingOrder->id,
'status' => $status
'status' => $status,
]);
$this->info("Aktualisiere Abo: {$userAbo->id} mit Status {$status}");
@ -330,18 +344,22 @@ class UserMakeAboOrder extends Command
'user_abo_id' => $userAbo->id,
'shopping_order_id' => $shoppingOrder->id,
'status' => $status,
'paid' => false,
'paid' => true,
]);
\Log::channel('abo_order')->info('UserMakeAboOrder: Abo erfolgreich aktualisiert', [
'abo_id' => $userAbo->id,
'next_date' => $updateData['next_date']
'next_date' => $updateData['next_date'],
]);
});
// Wie bei Payment::paymentStatusPaidAction: Incentive nur wenn Callback nicht lief
// (firstOrCreate verhindert Doppelungen wenn Payone später noch trackt)
IncentiveTracker::trackAboActivated($shoppingOrder);
} catch (\Exception $e) {
\Log::channel('abo_order')->error('UserMakeAboOrder: Fehler beim Aktualisieren des Abos', [
'abo_id' => $userAbo->id,
'error' => $e->getMessage()
'error' => $e->getMessage(),
]);
$this->error("Fehler beim Aktualisieren des Abos {$userAbo->id}: " . $e->getMessage());
throw $e; // Re-throw für besseres Error-Handling
@ -352,10 +370,10 @@ class UserMakeAboOrder extends Command
* Aktualisiert das Abo bei Fehlern - OHNE next_date zu aktualisieren
* Damit wird das Abo beim nächsten Cron-Lauf erneut versucht
*
* @param UserAbo $userAbo
* @param mixed $shoppingOrder
* @param int|string $status Status-Code oder Fehlermeldung
* @param array|null $errorResponse Optionale Fehlerantwort von Payment
* @param UserAbo $userAbo
* @param mixed $shoppingOrder
* @param int|string $status Status-Code oder Fehlermeldung
* @param array|null $errorResponse Optionale Fehlerantwort von Payment
* @return void
*/
private function updateAboOnError($userAbo, $shoppingOrder, $status, $errorResponse = null)
@ -363,7 +381,7 @@ class UserMakeAboOrder extends Command
\Log::channel('abo_order')->info('UserMakeAboOrder: Aktualisiere Abo bei Fehler (ohne next_date)', [
'abo_id' => $userAbo->id,
'order_id' => $shoppingOrder->id,
'status' => $status
'status' => $status,
]);
$this->info("Aktualisiere Abo bei Fehler: {$userAbo->id} (Status: {$status})");
@ -395,13 +413,13 @@ class UserMakeAboOrder extends Command
\Log::channel('abo_order')->info('UserMakeAboOrder: Abo bei Fehler aktualisiert (next_date unverändert)', [
'abo_id' => $userAbo->id,
'next_date' => $userAbo->next_date,
'status' => $status
'status' => $status,
]);
});
} catch (\Exception $e) {
\Log::channel('abo_order')->error('UserMakeAboOrder: Fehler beim Aktualisieren des Abos bei Fehler', [
'abo_id' => $userAbo->id,
'error' => $e->getMessage()
'error' => $e->getMessage(),
]);
$this->error("Fehler beim Aktualisieren des Abos {$userAbo->id}: " . $e->getMessage());
// Bei Fehler hier nicht re-throw, damit der Hauptprozess fortgesetzt werden kann

View file

@ -44,6 +44,12 @@ class Kernel extends ConsoleKernel
$schedule->command('user:cleanup')->dailyAt('03:30');
$schedule->command('user:make_abo_order')->dailyAt('04:00');
// Abo-Chart-Snapshots: vergangene Monate einfrieren (nach allen Abo-Jobs)
$schedule->command('abo:store-chart-snapshots')->dailyAt('04:30');
// Incentive: Punkteberechnung täglich nach business:store-optimized
$schedule->command('incentive:calculate')->dailyAt('05:00');
// Cleanup old log files weekly (keeps logs for 30 days)
$schedule->command('logs:cleanup --days=30')->weekly()->sundays()->at('05:00');