- UserMakeOrder: bestaetigte Einmal-Artikel in den Yard, is_abo_addon auf ShoppingOrderItem; amount bleibt reiner Abo-Betrag (Reihenfolge) - AboOneTimeService::purgeAfterExecution: loescht alle Einmal-Artikel und rechnet Comp-Produkte neu - nur im Erfolgszweig (Cron + Retry) - User-Retry in Sales Center und Portal mit Berechtigungspruefung, gemeinsames Confirm-Modal; Admin-Retry unveraendert - Tests: AboMakeOrderOneTimeTest, AboUserRetryTest; Plan-Doku Phase 4 Co-authored-by: Cursor <cursoragent@cursor.com>
446 lines
18 KiB
PHP
446 lines
18 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Cron\UserMakeOrder;
|
|
use App\Models\UserAbo;
|
|
use App\Models\UserAboOrder;
|
|
use App\Services\AboHelper;
|
|
use App\Services\AboOneTimeService;
|
|
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 UserMakeAboOrder extends Command
|
|
{
|
|
/**
|
|
* ln -sfv /usr/bin/php73 /usr/bin/php
|
|
* php artisan business:store month year
|
|
* The name and signature of the console command.
|
|
*
|
|
* @var string
|
|
*/
|
|
protected $signature = 'user:make_abo_order';
|
|
|
|
/**
|
|
* The console command description.
|
|
*
|
|
* @var string
|
|
*/
|
|
protected $description = 'Make Orders from Abos';
|
|
|
|
private $timeStart;
|
|
|
|
private $month;
|
|
|
|
private $year;
|
|
|
|
private $sendCreditMail = false;
|
|
|
|
private $sendUpdateMail = false;
|
|
|
|
/**
|
|
* Create a new command instance.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function __construct()
|
|
{
|
|
parent::__construct();
|
|
}
|
|
|
|
/**
|
|
* Execute the console command.
|
|
*
|
|
* @return int
|
|
*/
|
|
public function handle()
|
|
{
|
|
$this->timeStart = microtime(true);
|
|
\Log::channel('cron')->info('UserMakeAboOrder: Befehl gestartet');
|
|
$this->info('RUN Command user:make_abo_order');
|
|
|
|
try {
|
|
$this->checkAbosToOrder();
|
|
$executionTime = $this->getExecutionTime();
|
|
\Log::channel('cron')->info("UserMakeAboOrder: Befehl erfolgreich abgeschlossen in {$executionTime}");
|
|
$this->info("Befehl erfolgreich abgeschlossen in {$executionTime}");
|
|
|
|
return 0;
|
|
} catch (\Exception $e) {
|
|
\Log::channel('cron')->error('UserMakeAboOrder: Fehler beim Ausführen des Befehls', [
|
|
'error' => $e->getMessage(),
|
|
'trace' => $e->getTraceAsString(),
|
|
]);
|
|
$this->error('Fehler beim Ausführen des Befehls: '.$e->getMessage());
|
|
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Prüft alle Abos, die heute fällig sind und erstellt Bestellungen
|
|
*
|
|
* @return void
|
|
*/
|
|
private function checkAbosToOrder()
|
|
{
|
|
$dateNow = Carbon::now()->format('Y-m-d');
|
|
|
|
\Log::channel('abo_order')->info('UserMakeAboOrder: Suche nach fälligen Abos für Datum', ['date' => $dateNow]);
|
|
|
|
// 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
|
|
->whereDoesntHave('user_abo_orders', function ($query) use ($dateNow) {
|
|
$query->whereDate('created_at', $dateNow);
|
|
})
|
|
->get();
|
|
|
|
$count = $userAbos->count();
|
|
\Log::channel('abo_order')->info("UserMakeAboOrder: {$count} fällige Abos gefunden (ohne bereits verarbeitete)");
|
|
$this->info("Gefundene fällige Abos: {$count}");
|
|
|
|
foreach ($userAbos as $userAbo) {
|
|
\Log::channel('abo_order')->info('UserMakeAboOrder: Verarbeite Abo', [
|
|
'abo_id' => $userAbo->id,
|
|
'payone_userid' => $userAbo->payone_userid,
|
|
]);
|
|
|
|
$this->info("Verarbeite Abo: {$userAbo->id} (PayoneUserid: {$userAbo->payone_userid})");
|
|
|
|
try {
|
|
// Locking-Mechanismus: Verhindert Race Conditions bei paralleler Ausführung
|
|
$shoppingOrder = DB::transaction(function () use ($userAbo, $dateNow) {
|
|
// Lock das Abo für Update, um Race Conditions zu vermeiden
|
|
$lockedAbo = UserAbo::where('id', $userAbo->id)
|
|
->where('next_date', '=', $dateNow)
|
|
->where('active', true)
|
|
->where('status', '=', 2) // abo_okay
|
|
->lockForUpdate()
|
|
->first();
|
|
|
|
if (! $lockedAbo) {
|
|
\Log::channel('abo_order')->warning('UserMakeAboOrder: Abo wurde bereits verarbeitet oder ist nicht mehr aktiv', [
|
|
'abo_id' => $userAbo->id,
|
|
]);
|
|
|
|
return null;
|
|
}
|
|
|
|
// Nochmalige Prüfung auf Duplikat innerhalb der Transaktion
|
|
$existingOrder = UserAboOrder::where('user_abo_id', $lockedAbo->id)
|
|
->whereDate('created_at', $dateNow)
|
|
->first();
|
|
|
|
if ($existingOrder) {
|
|
\Log::channel('abo_order')->info('UserMakeAboOrder: Abo wurde bereits heute verarbeitet', [
|
|
'abo_id' => $lockedAbo->id,
|
|
'existing_order_id' => $existingOrder->shopping_order_id,
|
|
]);
|
|
|
|
return null;
|
|
}
|
|
|
|
return $this->makeOrder($lockedAbo);
|
|
}, 3); // 3 Versuche bei Deadlocks
|
|
|
|
if ($shoppingOrder) {
|
|
\Log::channel('abo_order')->info('UserMakeAboOrder: Bestellung erstellt', [
|
|
'abo_id' => $userAbo->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 (\Throwable $e) {
|
|
\Log::channel('abo_order')->error('UserMakeAboOrder: Fehler bei der Verarbeitung des Abos', [
|
|
'abo_id' => $userAbo->id,
|
|
'error' => $e->getMessage(),
|
|
'trace' => $e->getTraceAsString(),
|
|
]);
|
|
$this->error("Fehler bei Abo {$userAbo->id}: ".$e->getMessage());
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Erstellt eine Bestellung für ein Abo
|
|
*
|
|
* @param UserAbo $userAbo
|
|
* @return mixed
|
|
*/
|
|
private function makeOrder($userAbo)
|
|
{
|
|
\Log::channel('abo_order')->info('UserMakeAboOrder: Starte Bestellungserstellung', ['abo_id' => $userAbo->id]);
|
|
$this->info('Starte Bestellungserstellung für Abo: '.$userAbo->id);
|
|
|
|
$shoppingOrder = null;
|
|
$userOrder = new UserMakeOrder($userAbo);
|
|
|
|
try {
|
|
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) {
|
|
\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,
|
|
]);
|
|
|
|
$response = $userOrder->makePayment();
|
|
$this->info('makePayment response: '.json_encode($response));
|
|
|
|
// Prüfe ob Response ein Array ist (kann auch Objekt sein)
|
|
if (is_object($response)) {
|
|
$response = (array) $response;
|
|
}
|
|
|
|
if (! isset($response['status'])) {
|
|
\Log::channel('abo_order')->error('UserMakeAboOrder: Ungültige Zahlungsantwort', [
|
|
'abo_id' => $userAbo->id,
|
|
'order_id' => $shoppingOrder->id,
|
|
'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;
|
|
}
|
|
|
|
if ($response['status'] === 'APPROVED') {
|
|
\Log::channel('abo_order')->info('UserMakeAboOrder: Zahlung erfolgreich', [
|
|
'abo_id' => $userAbo->id,
|
|
'order_id' => $shoppingOrder->id,
|
|
'response' => $response,
|
|
]);
|
|
$this->info("Zahlung erfolgreich für Abo {$userAbo->id}");
|
|
// Nur bei erfolgreicher Zahlung: next_date aktualisieren
|
|
$this->updateAbo($userAbo, $shoppingOrder, 1);
|
|
} elseif ($response['status'] === 'ERROR') {
|
|
\Log::channel('abo_order')->error('UserMakeAboOrder: Zahlungsfehler', [
|
|
'abo_id' => $userAbo->id,
|
|
'order_id' => $shoppingOrder->id,
|
|
'error' => $response,
|
|
]);
|
|
$this->error("Zahlungsfehler für Abo {$userAbo->id}");
|
|
|
|
MyLog::writeLog(
|
|
'userabo',
|
|
'error',
|
|
'Error:3002 App\Console\Commands\UserMakeAboOrder::makeOrder / makePayment Error response',
|
|
$response
|
|
);
|
|
|
|
// Bei Zahlungsfehler: Status setzen, aber next_date NICHT aktualisieren
|
|
// Damit wird das Abo beim nächsten Cron-Lauf erneut versucht
|
|
$this->updateAboOnError($userAbo, $shoppingOrder, 3, $response);
|
|
|
|
$shoppingPayment = $userOrder->getShoppingPayment();
|
|
if ($shoppingPayment) {
|
|
$data = [
|
|
'mode' => $shoppingPayment->mode,
|
|
'txaction' => 'error',
|
|
'send_link' => false,
|
|
'payment_error' => $response,
|
|
];
|
|
|
|
Payment::paymentStatusSendMail($shoppingOrder, $shoppingPayment, $data);
|
|
}
|
|
} elseif ($response['status'] === 'PENDING' || $response['status'] === 'REDIRECT') {
|
|
// Pending/Redirect Status: Bestellung speichern, aber Abo nicht aktualisieren
|
|
\Log::channel('abo_order')->info('UserMakeAboOrder: Zahlung ausstehend/weiterleitung', [
|
|
'abo_id' => $userAbo->id,
|
|
'order_id' => $shoppingOrder->id,
|
|
'status' => $response['status'],
|
|
]);
|
|
$this->info("Zahlung ausstehend für Abo {$userAbo->id}: {$response['status']}");
|
|
$this->updateAboOnError($userAbo, $shoppingOrder, 'Zahlung ausstehend: '.$response['status']);
|
|
} else {
|
|
// Unbekannter Status: Bestellung speichern, aber Abo nicht aktualisieren
|
|
\Log::channel('abo_order')->warning('UserMakeAboOrder: Unbekannter Zahlungsstatus', [
|
|
'abo_id' => $userAbo->id,
|
|
'order_id' => $shoppingOrder->id,
|
|
'status' => $response['status'],
|
|
]);
|
|
$this->warn("Unbekannter Zahlungsstatus für Abo {$userAbo->id}: {$response['status']}");
|
|
$this->updateAboOnError($userAbo, $shoppingOrder, 'Unbekannter Status: '.$response['status']);
|
|
}
|
|
} catch (\Throwable $e) {
|
|
\Log::channel('abo_order')->error('UserMakeAboOrder: Ausnahme bei der Bestellungserstellung', [
|
|
'abo_id' => $userAbo->id,
|
|
'error' => $e->getMessage(),
|
|
'trace' => $e->getTraceAsString(),
|
|
]);
|
|
$this->error("Ausnahme bei Abo {$userAbo->id}: ".$e->getMessage());
|
|
|
|
// 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;
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* @return void
|
|
*/
|
|
private function updateAbo($userAbo, $shoppingOrder, $status = 1)
|
|
{
|
|
\Log::channel('abo_order')->info('UserMakeAboOrder: Aktualisiere Abo nach erfolgreicher Zahlung', [
|
|
'abo_id' => $userAbo->id,
|
|
'order_id' => $shoppingOrder->id,
|
|
'status' => $status,
|
|
]);
|
|
|
|
$this->info("Aktualisiere Abo: {$userAbo->id} mit Status {$status}");
|
|
|
|
try {
|
|
DB::transaction(function () use ($userAbo, $shoppingOrder, $status) {
|
|
$updateData = [
|
|
'next_date' => AboHelper::setNextDate(now(), $userAbo->abo_interval),
|
|
'last_date' => now(),
|
|
];
|
|
|
|
if ($status !== 1) {
|
|
$updateData['status'] = $status;
|
|
}
|
|
|
|
$userAbo->update($updateData);
|
|
|
|
UserAboOrder::create([
|
|
'user_abo_id' => $userAbo->id,
|
|
'shopping_order_id' => $shoppingOrder->id,
|
|
'status' => $status,
|
|
'paid' => true,
|
|
]);
|
|
|
|
\Log::channel('abo_order')->info('UserMakeAboOrder: Abo erfolgreich aktualisiert', [
|
|
'abo_id' => $userAbo->id,
|
|
'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);
|
|
|
|
// Nur bei Erfolg: Einmal-Artikel entfernen und Comp-Produkte neu bewerten.
|
|
AboOneTimeService::purgeAfterExecution($userAbo);
|
|
} catch (\Exception $e) {
|
|
\Log::channel('abo_order')->error('UserMakeAboOrder: Fehler beim Aktualisieren des Abos', [
|
|
'abo_id' => $userAbo->id,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
$this->error("Fehler beim Aktualisieren des Abos {$userAbo->id}: ".$e->getMessage());
|
|
throw $e; // Re-throw für besseres Error-Handling
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* @return void
|
|
*/
|
|
private function updateAboOnError($userAbo, $shoppingOrder, $status, $errorResponse = null)
|
|
{
|
|
\Log::channel('abo_order')->info('UserMakeAboOrder: Aktualisiere Abo bei Fehler (ohne next_date)', [
|
|
'abo_id' => $userAbo->id,
|
|
'order_id' => $shoppingOrder->id,
|
|
'status' => $status,
|
|
]);
|
|
|
|
$this->info("Aktualisiere Abo bei Fehler: {$userAbo->id} (Status: {$status})");
|
|
|
|
try {
|
|
DB::transaction(function () use ($userAbo, $shoppingOrder, $status) {
|
|
// Nur last_date aktualisieren, next_date bleibt unverändert
|
|
// Damit wird das Abo beim nächsten Cron-Lauf erneut versucht
|
|
$updateData = [
|
|
'last_date' => now(),
|
|
];
|
|
|
|
// Status nur setzen wenn es ein numerischer Wert ist
|
|
if (is_numeric($status)) {
|
|
$updateData['status'] = $status;
|
|
}
|
|
|
|
$userAbo->update($updateData);
|
|
|
|
// UserAboOrder mit Fehlerstatus speichern
|
|
$orderStatus = is_numeric($status) ? $status : 3; // Default zu 3 (abo_hold) wenn String
|
|
UserAboOrder::create([
|
|
'user_abo_id' => $userAbo->id,
|
|
'shopping_order_id' => $shoppingOrder->id,
|
|
'status' => $orderStatus,
|
|
'paid' => false,
|
|
]);
|
|
|
|
\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,
|
|
]);
|
|
});
|
|
} catch (\Exception $e) {
|
|
\Log::channel('abo_order')->error('UserMakeAboOrder: Fehler beim Aktualisieren des Abos bei Fehler', [
|
|
'abo_id' => $userAbo->id,
|
|
'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
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Berechnet die Ausführungszeit
|
|
*
|
|
* @return string
|
|
*/
|
|
private function getExecutionTime()
|
|
{
|
|
$diff = microtime(true) - $this->timeStart;
|
|
$sec = intval($diff);
|
|
$micro = $diff - $sec;
|
|
|
|
return $sec.' Sekunden und '.round($micro * 1000, 2).' ms';
|
|
}
|
|
}
|