Abo Einmalprodukte: Phasen 5-7, konfigurierbare Sperrfristen, Lokalisierung & Dashboard
Rechnung/Lieferschein: - PDF-Gruppierung Abo- vs. Einmal-Artikel (is_abo_addon), rueckwaertskompatibel - Vorschau-Command sales:preview-invoice (umgeht Test-System-Gate, ohne Rechnungsnummer) Reminder-Mail (vorbereitet, Cron inaktiv): - MailAboExecutionReminder + Template, abo:send-execution-reminder, Spalte onetime_reminder_sent_for - Berater -> Sales Center, Kunde -> Portal; Vorschau abo:preview-reminder - Datum-Fix: formatDateDB() statt formatDate() (ICU-Format) Sperrfristen konfigurierbar (Admin-Settings): - abo-lock-days-change (Liefertag + Abo-Produkte), abo-lock-days-pause-cancel - Meldungen mit :lock-Platzhalter; Pausieren/Kuendigen vom Change-Lock entkoppelt - Abo-Produkt-Aenderungen im Lock-Fenster server- + clientseitig eingefroren - Selbstbedienung: Add-only/Lock nach $view (kein Admin-Bypass), Stale-Cache-Fix beim Mengen-Update Dashboard: - Einmalprodukte-Card + Team-Abo-KPI-Card, News-Card responsiv (col-xl-6) Lokalisierung: - Sprachdateien en/es/fr vollstaendig mit de synchronisiert (inkl. marketingplan/portal) - LocalizationSyncTest sichert Key-Gleichheit ab Tests: AboOneTimeInvoiceGrouping, AboExecutionReminder, AboLockDaysSetting, AboOnetimeDashboardAndMail, PreviewInvoiceCommand, TeamAboOverview + erweiterte View-Tests Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
9f996a7f1b
commit
65b867f8ee
96 changed files with 4154 additions and 1189 deletions
110
app/Console/Commands/AboSendExecutionReminder.php
Normal file
110
app/Console/Commands/AboSendExecutionReminder.php
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Mail\MailAboExecutionReminder;
|
||||
use App\Models\UserAbo;
|
||||
use App\Services\AboHelper;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class AboSendExecutionReminder extends Command
|
||||
{
|
||||
/**
|
||||
* Reminder vor der Abo-Ausführung: weist auf das Zeitfenster für einmalige
|
||||
* Zusatzprodukte hin. Berater erhalten den Link ins Sales Center, Endkunden
|
||||
* ins Kundenportal.
|
||||
*/
|
||||
protected $signature = 'abo:send-execution-reminder {--dry-run : Nur zählen, nichts versenden}';
|
||||
|
||||
protected $description = 'Versendet Reminder-Mails für anstehende Abo-Lieferungen (Einmalprodukte-Fenster)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$windowDays = AboHelper::getOneTimeWindowDays();
|
||||
$targetDate = Carbon::today()->addDays($windowDays);
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
$abos = UserAbo::query()
|
||||
->with(['user', 'shopping_user'])
|
||||
->where('active', true)
|
||||
->where('status', 2)
|
||||
->whereIn('is_for', ['me', 'ot'])
|
||||
->whereDate('next_date', $targetDate->toDateString())
|
||||
->where(function ($query) use ($targetDate): void {
|
||||
$query->whereNull('onetime_reminder_sent_for')
|
||||
->orWhereDate('onetime_reminder_sent_for', '!=', $targetDate->toDateString());
|
||||
})
|
||||
->get();
|
||||
|
||||
$sent = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($abos as $abo) {
|
||||
$email = $this->recipientEmail($abo);
|
||||
|
||||
if (! $email) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$sent++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
Mail::to($email)
|
||||
->locale($this->recipientLocale($abo))
|
||||
->send(new MailAboExecutionReminder($abo));
|
||||
|
||||
$abo->onetime_reminder_sent_for = $abo->next_date;
|
||||
$abo->save();
|
||||
$sent++;
|
||||
} catch (\Throwable $e) {
|
||||
$skipped++;
|
||||
Log::channel('abo_order')->error('AboSendExecutionReminder: Versand fehlgeschlagen', [
|
||||
'abo_id' => $abo->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'Reminder %s: %d versendet, %d übersprungen (Zieldatum %s, Fenster %d Tage).',
|
||||
$dryRun ? '(dry-run)' : '',
|
||||
$sent,
|
||||
$skipped,
|
||||
$targetDate->toDateString(),
|
||||
$windowDays
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function recipientEmail(UserAbo $abo): ?string
|
||||
{
|
||||
if ($abo->email) {
|
||||
return $abo->email;
|
||||
}
|
||||
|
||||
if ($abo->is_for === 'me') {
|
||||
return $abo->user?->email;
|
||||
}
|
||||
|
||||
return $abo->shopping_user?->billing_email;
|
||||
}
|
||||
|
||||
private function recipientLocale(UserAbo $abo): string
|
||||
{
|
||||
$locale = $abo->is_for === 'me'
|
||||
? $abo->user?->getLocale()
|
||||
: $abo->shopping_user?->getLocale();
|
||||
|
||||
return $locale ?: 'de';
|
||||
}
|
||||
}
|
||||
67
app/Console/Commands/PreviewAboReminder.php
Normal file
67
app/Console/Commands/PreviewAboReminder.php
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Mail\MailAboExecutionReminder;
|
||||
use App\Models\UserAbo;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class PreviewAboReminder extends Command
|
||||
{
|
||||
/**
|
||||
* Rendert die Abo-Ausführungs-Reminder-Mail für ein Abo als HTML-Vorschau
|
||||
* (und sendet sie optional via --send an eine Adresse, z. B. zum Test in Mailpit).
|
||||
* Verbraucht/ändert nichts am Abo.
|
||||
*/
|
||||
protected $signature = 'abo:preview-reminder {abo : UserAbo-ID} {--send= : Mail zusätzlich an diese Adresse senden (Mailpit-Test)}';
|
||||
|
||||
protected $description = 'Vorschau der Abo-Reminder-Mail erzeugen (HTML) und optional testweise versenden';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$abo = UserAbo::with(['user', 'shopping_user'])->find((int) $this->argument('abo'));
|
||||
|
||||
if (! $abo) {
|
||||
$this->error('UserAbo '.$this->argument('abo').' nicht gefunden.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$locale = $this->recipientLocale($abo);
|
||||
$originalLocale = App::getLocale();
|
||||
|
||||
App::setLocale($locale);
|
||||
try {
|
||||
$html = (new MailAboExecutionReminder($abo))->render();
|
||||
} finally {
|
||||
App::setLocale($originalLocale);
|
||||
}
|
||||
|
||||
$path = 'preview/abo-reminder-'.$abo->id.'.html';
|
||||
Storage::disk('public')->put($path, $html);
|
||||
|
||||
$url = preg_replace('#(?<!:)//+#', '/', Storage::disk('public')->url($path));
|
||||
$this->info("Reminder-Vorschau für Abo {$abo->id} (Locale {$locale}, ".($abo->is_for === 'me' ? 'Berater → Sales Center' : 'Endkunde → Portal').'):');
|
||||
$this->line(' HTML: '.$url);
|
||||
$this->line(' Datei: '.Storage::disk('public')->path($path));
|
||||
|
||||
if ($send = $this->option('send')) {
|
||||
Mail::to($send)->locale($locale)->send(new MailAboExecutionReminder($abo));
|
||||
$this->info(" Mail an {$send} versendet (siehe Mailpit).");
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function recipientLocale(UserAbo $abo): string
|
||||
{
|
||||
$locale = $abo->is_for === 'me'
|
||||
? $abo->user?->getLocale()
|
||||
: $abo->shopping_user?->getLocale();
|
||||
|
||||
return $locale ?: 'de';
|
||||
}
|
||||
}
|
||||
53
app/Console/Commands/PreviewInvoice.php
Normal file
53
app/Console/Commands/PreviewInvoice.php
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Repositories\InvoiceRepository;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class PreviewInvoice extends Command
|
||||
{
|
||||
/**
|
||||
* Erzeugt eine Vorschau der Rechnungs-/Lieferschein-PDFs einer Bestellung,
|
||||
* ohne eine offizielle Rechnung anzulegen (keine Rechnungsnummer, kein Tracking).
|
||||
* Nützlich auf Testsystemen, wo die reguläre Rechnungserzeugung deaktiviert ist.
|
||||
*/
|
||||
protected $signature = 'sales:preview-invoice {order : ShoppingOrder-ID}';
|
||||
|
||||
protected $description = 'Vorschau-PDFs (Rechnung + Lieferschein) einer Bestellung erzeugen – ohne offizielle Rechnung';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$orderId = (int) $this->argument('order');
|
||||
$order = ShoppingOrder::with('shopping_order_items.product')->find($orderId);
|
||||
|
||||
if (! $order) {
|
||||
$this->error("ShoppingOrder {$orderId} nicht gefunden.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
$paths = (new InvoiceRepository($order))->makePreview();
|
||||
} catch (\Throwable $e) {
|
||||
$this->error('Fehler bei der Vorschau-Erzeugung: '.$e->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info("Vorschau für Bestellung {$orderId} erzeugt (keine offizielle Rechnung, keine Nummer verbraucht):");
|
||||
foreach ($paths as $type => $path) {
|
||||
$label = $type === 'invoice' ? 'Rechnung ' : 'Lieferschein';
|
||||
$url = preg_replace('#(?<!:)//+#', '/', Storage::disk('public')->url($path));
|
||||
$this->line(" {$label}: ".$url);
|
||||
$this->line(" {$label} (Datei): ".Storage::disk('public')->path($path));
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->comment('Hinweis: Der Umsatzpunkte-Block entfällt in der Vorschau (kein Tracking).');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -23,7 +23,9 @@ class UserMakeAboOrder extends Command
|
|||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'user:make_abo_order';
|
||||
protected $signature = 'user:make_abo_order
|
||||
{--abo= : Nur dieses eine Abo (ID) ausführen}
|
||||
{--force : Datums- und Duplikatsprüfung überspringen (manueller Testlauf)}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
|
|
@ -89,17 +91,38 @@ class UserMakeAboOrder extends Command
|
|||
private function checkAbosToOrder()
|
||||
{
|
||||
$dateNow = Carbon::now()->format('Y-m-d');
|
||||
$forceAboId = $this->option('abo') !== null ? (int) $this->option('abo') : null;
|
||||
$force = (bool) $this->option('force');
|
||||
|
||||
\Log::channel('abo_order')->info('UserMakeAboOrder: Suche nach fälligen Abos für Datum', ['date' => $dateNow]);
|
||||
\Log::channel('abo_order')->info('UserMakeAboOrder: Suche nach fälligen Abos für Datum', [
|
||||
'date' => $dateNow,
|
||||
'force_abo_id' => $forceAboId,
|
||||
'force' => $force,
|
||||
]);
|
||||
|
||||
// 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();
|
||||
if ($force) {
|
||||
$this->warn('FORCE-Modus aktiv: Datums-/Duplikatsprüfung wird übersprungen (echte Bestellung + Zahlung!).');
|
||||
}
|
||||
|
||||
$query = UserAbo::query();
|
||||
|
||||
if ($forceAboId !== null) {
|
||||
$query->where('id', $forceAboId);
|
||||
} else {
|
||||
$query->where('next_date', '=', $dateNow);
|
||||
}
|
||||
|
||||
// Im Force-Modus werden die regulären Filter (fällig/aktiv/status/Duplikat) übersprungen,
|
||||
// damit ein einzelnes Abo on-demand getestet werden kann.
|
||||
if (! $force) {
|
||||
$query->where('active', true)
|
||||
->where('status', '=', 2) // abo_okay
|
||||
->whereDoesntHave('user_abo_orders', function ($query) use ($dateNow) {
|
||||
$query->whereDate('created_at', $dateNow);
|
||||
});
|
||||
}
|
||||
|
||||
$userAbos = $query->get();
|
||||
|
||||
$count = $userAbos->count();
|
||||
\Log::channel('abo_order')->info("UserMakeAboOrder: {$count} fällige Abos gefunden (ohne bereits verarbeitete)");
|
||||
|
|
@ -115,14 +138,15 @@ class UserMakeAboOrder extends Command
|
|||
|
||||
try {
|
||||
// Locking-Mechanismus: Verhindert Race Conditions bei paralleler Ausführung
|
||||
$shoppingOrder = DB::transaction(function () use ($userAbo, $dateNow) {
|
||||
$shoppingOrder = DB::transaction(function () use ($userAbo, $dateNow, $force) {
|
||||
// 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();
|
||||
$lockQuery = UserAbo::where('id', $userAbo->id);
|
||||
if (! $force) {
|
||||
$lockQuery->where('next_date', '=', $dateNow)
|
||||
->where('active', true)
|
||||
->where('status', '=', 2); // abo_okay
|
||||
}
|
||||
$lockedAbo = $lockQuery->lockForUpdate()->first();
|
||||
|
||||
if (! $lockedAbo) {
|
||||
\Log::channel('abo_order')->warning('UserMakeAboOrder: Abo wurde bereits verarbeitet oder ist nicht mehr aktiv', [
|
||||
|
|
@ -132,18 +156,20 @@ class UserMakeAboOrder extends Command
|
|||
return null;
|
||||
}
|
||||
|
||||
// Nochmalige Prüfung auf Duplikat innerhalb der Transaktion
|
||||
$existingOrder = UserAboOrder::where('user_abo_id', $lockedAbo->id)
|
||||
->whereDate('created_at', $dateNow)
|
||||
->first();
|
||||
// Nochmalige Prüfung auf Duplikat innerhalb der Transaktion (im Force-Modus übersprungen)
|
||||
if (! $force) {
|
||||
$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,
|
||||
]);
|
||||
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 null;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->makeOrder($lockedAbo);
|
||||
|
|
|
|||
|
|
@ -52,6 +52,11 @@ class Kernel extends ConsoleKernel
|
|||
$schedule->command('user:cleanup')->dailyAt('03:30');
|
||||
$schedule->command('user:make_abo_order')->dailyAt('04:00');
|
||||
|
||||
// Abo-Einmalprodukte: Reminder-Mail vor der Ausführung (Einmalprodukte-Fenster).
|
||||
// BEWUSST INAKTIV bis zum Abschluss des Review-Gates (Feature aktuell nur VIP
|
||||
// im Sales Center, Portal ausgeblendet). Nach Freigabe einkommentieren:
|
||||
// $schedule->command('abo:send-execution-reminder')->dailyAt('06:00');
|
||||
|
||||
// Abo-Chart-Snapshots: vergangene Monate einfrieren (nach allen Abo-Jobs)
|
||||
$schedule->command('abo:store-chart-snapshots')->dailyAt('04:30');
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ use App\Models\DashboardNews;
|
|||
use App\Models\Incentive;
|
||||
use App\Models\IncentiveParticipant;
|
||||
use App\Models\ShoppingPayment;
|
||||
use App\Models\UserAbo;
|
||||
use App\Services\AboHelper;
|
||||
use App\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
|
@ -61,12 +63,36 @@ class HomeController extends Controller
|
|||
}
|
||||
}
|
||||
|
||||
// Eigenes Abo des Beraters für die Einmalprodukte-Dashboard-Card (nur wenn
|
||||
// das Zeitfenster offen ist und das Feature für den User sichtbar ist).
|
||||
$oneTimeAbo = null;
|
||||
$aboOneTimeId = UserAbo::where('user_id', $user->id)
|
||||
->where('is_for', 'me')
|
||||
->where('active', true)
|
||||
->where('status', 2)
|
||||
->value('id');
|
||||
|
||||
if ($aboOneTimeId) {
|
||||
$abo = UserAbo::find($aboOneTimeId);
|
||||
if ($abo && AboHelper::isOneTimeWindowOpen($abo) && AboHelper::isOneTimeFeatureVisible($abo)) {
|
||||
$oneTimeAbo = $abo;
|
||||
}
|
||||
}
|
||||
|
||||
$teamAboKpis = null;
|
||||
if ($user->isActiveAccount()) {
|
||||
$teamAboKpis = app(\App\Services\Abo\TeamAboOverviewService::class)
|
||||
->teamKpis($user, (int) Carbon::now()->month, (int) Carbon::now()->year);
|
||||
}
|
||||
|
||||
$data = [
|
||||
'user' => $user,
|
||||
'now' => Carbon::now(),
|
||||
'dashboardNews' => DashboardNews::getActiveNews(),
|
||||
'activeIncentive' => $activeIncentive,
|
||||
'incentiveParticipant' => $incentiveParticipant,
|
||||
'oneTimeAbo' => $oneTimeAbo,
|
||||
'teamAboKpis' => $teamAboKpis,
|
||||
];
|
||||
|
||||
return view('home', $data);
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ class AboController extends Controller
|
|||
|
||||
$shopping_user = ShoppingUser::findOrFail($user->shopping_user_id);
|
||||
$user_abo = UserAbo::where('email', $shopping_user->billing_email)
|
||||
->where('status', '>', 1)
|
||||
->where('status', '>=', 1)
|
||||
->first();
|
||||
|
||||
if (! $user_abo) {
|
||||
|
|
@ -99,6 +99,8 @@ class AboController extends Controller
|
|||
$user_abo = UserAbo::findOrFail($id);
|
||||
$this->checkPortalPermission($user_abo);
|
||||
$isAddOnlyMode = AboHelper::isAddOnlyMode($user_abo, $view);
|
||||
// Sperrfrist (Liefertag/Abo-Produkte): innerhalb des Fensters keine Produktänderungen.
|
||||
$changeLocked = AboHelper::isAboChangeLocked($user_abo, $view);
|
||||
|
||||
if (isset($data['action'])) {
|
||||
if ($data['action'] === 'abo_update_settings') {
|
||||
|
|
@ -116,8 +118,15 @@ class AboController extends Controller
|
|||
// Yard für Gewichts-/Versandprüfung initialisieren (Versandland setzen)
|
||||
AboOrderCart::initYard($user_abo);
|
||||
|
||||
if ($changeLocked) {
|
||||
$message = __('abo.error_change_locked', [
|
||||
'days' => (int) now()->diffInDays(\Carbon\Carbon::parse($user_abo->next_date), false),
|
||||
'lock' => \App\Repositories\AboRepository::getLockDaysChange(),
|
||||
]);
|
||||
}
|
||||
|
||||
// addProduct
|
||||
if ($data['action'] === 'addProduct') {
|
||||
if (! $changeLocked && $data['action'] === 'addProduct') {
|
||||
if ($product = Product::find($data['product_id'])) {
|
||||
if (AboOrderCart::exceedsMaxWeight($user_abo, (int) $product->weight)) {
|
||||
$message = __('msg.cart_max_weight_reached');
|
||||
|
|
@ -140,7 +149,7 @@ class AboController extends Controller
|
|||
}
|
||||
|
||||
// updateCart
|
||||
if ($data['action'] === 'updateCart') {
|
||||
if (! $changeLocked && $data['action'] === 'updateCart') {
|
||||
if (isset($data['product_id']) && $product = Product::find($data['product_id'])) {
|
||||
if (isset($data['order_item_id']) && $UserAboItem = UserAboItem::find($data['order_item_id'])) {
|
||||
if (isset($data['qty'])) {
|
||||
|
|
@ -165,7 +174,7 @@ class AboController extends Controller
|
|||
}
|
||||
|
||||
// removeFromCart
|
||||
if ($data['action'] === 'removeFromCart') {
|
||||
if (! $changeLocked && $data['action'] === 'removeFromCart') {
|
||||
if ($isAddOnlyMode) {
|
||||
return response()->json([
|
||||
'response' => false,
|
||||
|
|
@ -190,7 +199,7 @@ class AboController extends Controller
|
|||
}
|
||||
|
||||
// updateCompProduct
|
||||
if ($data['action'] === 'updateCompProduct') {
|
||||
if (! $changeLocked && $data['action'] === 'updateCompProduct') {
|
||||
if ($UserAboItem = UserAboItem::where('user_abo_id', $user_abo->id)->where('comp', $data['comp_num'])->first()) {
|
||||
$oldProduct = $UserAboItem->product;
|
||||
$UserAboItem->product_id = $data['comp_product_id'];
|
||||
|
|
@ -217,6 +226,10 @@ class AboController extends Controller
|
|||
}
|
||||
AboOrderCart::checkNumOfCompProducts($user_abo);
|
||||
|
||||
// Relation neu laden, damit die gerenderte Menge dem gespeicherten Stand entspricht
|
||||
// (sonst zeigt _order_abo_show die gecachte alte Menge → Feld „springt" zurück).
|
||||
$user_abo->load('user_abo_items');
|
||||
|
||||
$summary = AboOrderCart::getSplitSummary();
|
||||
$error_message = $message ? $message : false;
|
||||
$html_cart = view('admin.abo._order_abo_show', [
|
||||
|
|
@ -225,6 +238,7 @@ class AboController extends Controller
|
|||
'split_mode' => AboHelper::isOneTimeFeatureVisible($user_abo),
|
||||
'summary' => $summary,
|
||||
'add_only_mode' => $isAddOnlyMode,
|
||||
'change_locked' => $changeLocked,
|
||||
])->render();
|
||||
$html_comp = view('user.order.comp_product', array_merge($data, [
|
||||
'cart_instance' => AboOrderCart::INSTANCE,
|
||||
|
|
@ -353,6 +367,7 @@ class AboController extends Controller
|
|||
$user_abo = UserAbo::findOrFail($id);
|
||||
$this->checkPortalPermission($user_abo);
|
||||
$isAddOnlyMode = AboHelper::isAddOnlyMode($user_abo, $view);
|
||||
$changeLocked = AboHelper::isAboChangeLocked($user_abo, $view);
|
||||
|
||||
if (! AboHelper::isOneTimeFeatureVisible($user_abo)) {
|
||||
return response()->json([
|
||||
|
|
@ -389,6 +404,7 @@ class AboController extends Controller
|
|||
'summary' => $summary,
|
||||
'only_show_products' => false,
|
||||
'add_only_mode' => $isAddOnlyMode,
|
||||
'change_locked' => $changeLocked,
|
||||
])->render(),
|
||||
'html_summary' => view('admin.abo._order_combined_summary', [
|
||||
'summary' => $summary,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ namespace App\Http\Controllers\User;
|
|||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Abo\AboOneTimeItemRequest;
|
||||
use App\Models\Product;
|
||||
use App\Models\ShoppingInstance;
|
||||
use App\Models\UserAbo;
|
||||
use App\Models\UserAboItem;
|
||||
use App\Models\UserAboOneTimeItem;
|
||||
|
|
@ -13,6 +14,7 @@ use App\Services\AboHelper;
|
|||
use App\Services\AboItemHistoryService;
|
||||
use App\Services\AboOrderCart;
|
||||
use App\Services\AboRetryPaymentService;
|
||||
use App\Services\OrderPaymentService;
|
||||
use App\Services\Shop;
|
||||
use App\User;
|
||||
use Request;
|
||||
|
|
@ -33,15 +35,25 @@ class AboController extends Controller
|
|||
|
||||
if ($view === 'me') {
|
||||
// Nur Abos des aktuellen Benutzers
|
||||
$user_abos = UserAbo::where('user_id', \Auth::user()->id)
|
||||
->where('status', '>', 1);
|
||||
$baseQuery = UserAbo::where('user_id', \Auth::user()->id)
|
||||
->where('status', '>=', 1)
|
||||
->where('is_for', 'me');
|
||||
|
||||
if ((clone $baseQuery)->where('status', 1)->exists()) {
|
||||
return view('user.abo.index', [
|
||||
'user_abos' => (clone $baseQuery)->orderBy('id', 'desc')->get(),
|
||||
'view' => 'me',
|
||||
'isAdmin' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
$user_abos = (clone $baseQuery)->orderBy('id', 'desc')->get();
|
||||
if ($user_abos->count() > 0) {
|
||||
return redirect(route('user_abos_detail', ['me', $user_abos->first()->id]));
|
||||
}
|
||||
|
||||
return view('user.abo.index', [
|
||||
'user_abos' => [],
|
||||
'user_abos' => $user_abos,
|
||||
'view' => 'me',
|
||||
'isAdmin' => false,
|
||||
]);
|
||||
|
|
@ -49,23 +61,28 @@ class AboController extends Controller
|
|||
|
||||
if ($view === 'ot') {
|
||||
$selectedYear = (int) \Request::get('year', now()->year);
|
||||
$selectedMonth = (int) \Request::get('month', 0);
|
||||
$baseQuery = UserAbo::where('member_id', \Auth::user()->id)
|
||||
->where('status', '>', 1)
|
||||
->where('status', '>=', 1)
|
||||
->where('is_for', 'ot');
|
||||
|
||||
$user_abos = (clone $baseQuery)
|
||||
->with(['user_abo_items', 'user_abo_items.product', 'shopping_user'])
|
||||
->addedInMonth($selectedYear, $selectedMonth)
|
||||
->orderBy('id', 'desc')
|
||||
->get();
|
||||
|
||||
return view('user.abo.index', [
|
||||
'user_abos' => $user_abos,
|
||||
'abo_payment_links' => $this->getAboPaymentLinks(),
|
||||
'view' => 'ot',
|
||||
'isAdmin' => false,
|
||||
'chartData' => AboHelper::getMonthlyAboCounts($baseQuery, $selectedYear, 'ot', \Auth::user()->id),
|
||||
'chartYear' => $selectedYear,
|
||||
'chartYears' => \App\Services\HTMLHelper::getYearRange(2026),
|
||||
'chartMonths' => \App\Services\HTMLHelper::getTransMonths(),
|
||||
'filterMonth' => $selectedMonth,
|
||||
'filterYear' => $selectedYear,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -123,7 +140,11 @@ class AboController extends Controller
|
|||
$user_abo = UserAbo::findOrFail($id);
|
||||
$this->checkPermissions($view, $user_abo);
|
||||
$editView = \Auth::user()?->isAdmin() ? 'admin' : $view;
|
||||
$isAddOnlyMode = AboHelper::isAddOnlyMode($user_abo, $editView);
|
||||
// Add-only (Mindestlaufzeit) und Change-Lock richten sich nach der Abo-Sicht ($view),
|
||||
// NICHT nach Admin-Status – auf der Selbstbedienungs-Seite gelten für alle (auch VIP/Admin)
|
||||
// die Berater-Regeln. $editView dient nur dem History-Logging.
|
||||
$isAddOnlyMode = AboHelper::isAddOnlyMode($user_abo, $view);
|
||||
$changeLocked = AboHelper::isAboChangeLocked($user_abo, $view);
|
||||
|
||||
if (isset($data['action'])) {
|
||||
if ($data['action'] === 'abo_update_settings') {
|
||||
|
|
@ -138,8 +159,15 @@ class AboController extends Controller
|
|||
$message = false;
|
||||
// Yard für Gewichts-/Versandprüfung initialisieren (Versandland setzen)
|
||||
AboOrderCart::initYard($user_abo);
|
||||
|
||||
if ($changeLocked) {
|
||||
$message = __('abo.error_change_locked', [
|
||||
'days' => (int) now()->diffInDays(\Carbon\Carbon::parse($user_abo->next_date), false),
|
||||
'lock' => \App\Repositories\AboRepository::getLockDaysChange(),
|
||||
]);
|
||||
}
|
||||
// addProduct
|
||||
if ($data['action'] === 'addProduct') {
|
||||
if (! $changeLocked && $data['action'] === 'addProduct') {
|
||||
if ($product = Product::find($data['product_id'])) {
|
||||
if (AboOrderCart::exceedsMaxWeight($user_abo, (int) $product->weight)) {
|
||||
$message = __('msg.cart_max_weight_reached');
|
||||
|
|
@ -162,7 +190,7 @@ class AboController extends Controller
|
|||
}
|
||||
|
||||
// updateCart
|
||||
if ($data['action'] === 'updateCart') {
|
||||
if (! $changeLocked && $data['action'] === 'updateCart') {
|
||||
// product_id | order_item_id | cart_order_id | qty
|
||||
if (isset($data['product_id']) && $product = Product::find($data['product_id'])) {
|
||||
if (isset($data['order_item_id']) && $UserAboItem = UserAboItem::find($data['order_item_id'])) {
|
||||
|
|
@ -188,7 +216,7 @@ class AboController extends Controller
|
|||
}
|
||||
|
||||
// removeFromCart
|
||||
if ($data['action'] === 'removeFromCart') {
|
||||
if (! $changeLocked && $data['action'] === 'removeFromCart') {
|
||||
if ($isAddOnlyMode) {
|
||||
return response()->json([
|
||||
'response' => false,
|
||||
|
|
@ -212,7 +240,7 @@ class AboController extends Controller
|
|||
}
|
||||
}
|
||||
// updateCompProduct
|
||||
if ($data['action'] === 'updateCompProduct') {
|
||||
if (! $changeLocked && $data['action'] === 'updateCompProduct') {
|
||||
if ($UserAboItem = UserAboItem::where('user_abo_id', $user_abo->id)->where('comp', $data['comp_num'])->first()) {
|
||||
$oldProduct = $UserAboItem->product;
|
||||
$UserAboItem->product_id = $data['comp_product_id'];
|
||||
|
|
@ -239,6 +267,10 @@ class AboController extends Controller
|
|||
}
|
||||
AboOrderCart::checkNumOfCompProducts($user_abo); // after reCalculateShippingPrice check it and remove or add comp product
|
||||
|
||||
// Relation neu laden, damit die gerenderte Menge dem gespeicherten Stand
|
||||
// entspricht (sonst zeigt _order_abo_show die gecachte alte Menge → Feld „springt" zurück).
|
||||
$user_abo->load('user_abo_items');
|
||||
|
||||
if ($user_abo->is_for === 'me') {
|
||||
$data['comp_products'] = Shop::getCompProducts('abo-me');
|
||||
}
|
||||
|
|
@ -250,6 +282,7 @@ class AboController extends Controller
|
|||
'split_mode' => AboHelper::isOneTimeFeatureVisible($user_abo),
|
||||
'summary' => $summary,
|
||||
'add_only_mode' => $isAddOnlyMode,
|
||||
'change_locked' => $changeLocked,
|
||||
])->render();
|
||||
$html_comp = view('user.order.comp_product', array_merge($data, [
|
||||
'cart_instance' => AboOrderCart::INSTANCE,
|
||||
|
|
@ -271,8 +304,9 @@ class AboController extends Controller
|
|||
{
|
||||
$user_abo = UserAbo::findOrFail($id);
|
||||
$this->checkPermissions($view, $user_abo);
|
||||
$editView = \Auth::user()?->isAdmin() ? 'admin' : $view;
|
||||
$isAddOnlyMode = AboHelper::isAddOnlyMode($user_abo, $editView);
|
||||
// Add-only/Change-Lock nach Abo-Sicht ($view), kein Admin-Bypass (siehe update()).
|
||||
$isAddOnlyMode = AboHelper::isAddOnlyMode($user_abo, $view);
|
||||
$changeLocked = AboHelper::isAboChangeLocked($user_abo, $view);
|
||||
|
||||
if (! AboHelper::isOneTimeFeatureVisible($user_abo)) {
|
||||
return response()->json([
|
||||
|
|
@ -310,6 +344,7 @@ class AboController extends Controller
|
|||
'summary' => $summary,
|
||||
'only_show_products' => false,
|
||||
'add_only_mode' => $isAddOnlyMode,
|
||||
'change_locked' => $changeLocked,
|
||||
])->render(),
|
||||
'html_summary' => view('admin.abo._order_combined_summary', [
|
||||
'summary' => $summary,
|
||||
|
|
@ -520,6 +555,67 @@ class AboController extends Controller
|
|||
return redirect(route('user_abos_detail', [$view, $id]));
|
||||
}
|
||||
|
||||
public function deletePending($view, $id)
|
||||
{
|
||||
$user_abo = UserAbo::findOrFail($id);
|
||||
$this->checkPermissions($view, $user_abo);
|
||||
|
||||
if (! $user_abo->canDeletePendingInitialPayment()) {
|
||||
\Session()->flash('alert-error', __('abo.pending_delete_not_allowed'));
|
||||
|
||||
return redirect(route('user_abos', [$view]));
|
||||
}
|
||||
|
||||
\DB::transaction(function () use ($user_abo) {
|
||||
$user_abo->delete();
|
||||
});
|
||||
|
||||
\Session()->flash('alert-success', __('abo.pending_delete_success'));
|
||||
|
||||
return redirect(route('user_abos', [$view]));
|
||||
}
|
||||
|
||||
public function deletePaymentLink($identifier)
|
||||
{
|
||||
$shoppingInstance = ShoppingInstance::where('identifier', $identifier)->firstOrFail();
|
||||
$user = \Auth::user();
|
||||
$userShopId = $user && $user->shop ? $user->shop->id : null;
|
||||
|
||||
if ((int) $shoppingInstance->user_shop_id !== (int) $userShopId || ! OrderPaymentService::isAboCustomerPaymentLink($shoppingInstance)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! OrderPaymentService::canDeleteInstance($shoppingInstance)) {
|
||||
\Session()->flash('alert-error', __('abo.payment_link_delete_not_allowed'));
|
||||
|
||||
return redirect(route('user_abos', ['ot']));
|
||||
}
|
||||
|
||||
OrderPaymentService::deleteInstance($identifier);
|
||||
\Session()->flash('alert-success', __('abo.payment_link_delete_success'));
|
||||
|
||||
return redirect(route('user_abos', ['ot']));
|
||||
}
|
||||
|
||||
private function getAboPaymentLinks()
|
||||
{
|
||||
$user = \Auth::user();
|
||||
$userShopId = $user && $user->shop ? $user->shop->id : null;
|
||||
|
||||
if (! $userShopId) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return ShoppingInstance::where('user_shop_id', $userShopId)
|
||||
->where('payment', 6)
|
||||
->where('status', '<', 10)
|
||||
->orderBy('created_at', 'desc')
|
||||
->get()
|
||||
->filter(fn (ShoppingInstance $shoppingInstance) => OrderPaymentService::isAboCustomerPaymentLink($shoppingInstance))
|
||||
->reject(fn (ShoppingInstance $shoppingInstance) => OrderPaymentService::hasUserAboForPaymentLink($shoppingInstance, $user))
|
||||
->values();
|
||||
}
|
||||
|
||||
private function checkPermissions($view, $user_abo)
|
||||
{
|
||||
\Log::info('checkPermissions', ['view' => $view, 'user_abo' => $user_abo]);
|
||||
|
|
|
|||
69
app/Mail/MailAboExecutionReminder.php
Normal file
69
app/Mail/MailAboExecutionReminder.php
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\UserAbo;
|
||||
use App\Services\AboHelper;
|
||||
use App\Services\Util;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class MailAboExecutionReminder extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public function __construct(public UserAbo $userAbo) {}
|
||||
|
||||
public function build(): self
|
||||
{
|
||||
$isConsultant = $this->userAbo->is_for === 'me';
|
||||
|
||||
$salutation = __('email.hello');
|
||||
$firstname = $this->userAbo->shopping_user?->billing_firstname;
|
||||
if ($firstname) {
|
||||
$salutation = __('email.hello').' '.$firstname;
|
||||
}
|
||||
|
||||
$days = $this->daysUntilExecution();
|
||||
|
||||
return $this->subject(__('email.abo_reminder_subject'))
|
||||
->view('emails.abo_execution_reminder')
|
||||
->with([
|
||||
'salutation' => $salutation,
|
||||
'isConsultant' => $isConsultant,
|
||||
'days' => $days,
|
||||
'nextDate' => $this->userAbo->next_date
|
||||
? Carbon::parse($this->userAbo->next_date)->format(\Util::formatDateDB())
|
||||
: '',
|
||||
'link' => $this->buildLink($isConsultant),
|
||||
'greetings' => __('email.greetings'),
|
||||
'sender' => __('email.sender'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Berater erhalten den Link ins Sales Center (my.mivita.care), Endkunden ins
|
||||
* Kundenportal (in.mivita.care) – jeweils zur Abo-Detail-/Übersichtsseite.
|
||||
*/
|
||||
private function buildLink(bool $isConsultant): string
|
||||
{
|
||||
if ($isConsultant) {
|
||||
return rtrim(Util::getMyMivitaUrl(), '/').'/user/abos/detail/me/'.$this->userAbo->id;
|
||||
}
|
||||
|
||||
return rtrim(Util::getMyMivitaPortalUrl(), '/').'/portal/my-subscriptions';
|
||||
}
|
||||
|
||||
private function daysUntilExecution(): int
|
||||
{
|
||||
if (! $this->userAbo->next_date) {
|
||||
return AboHelper::getOneTimeWindowDays();
|
||||
}
|
||||
|
||||
$days = Carbon::today()->diffInDays(Carbon::parse($this->userAbo->next_date)->startOfDay(), false);
|
||||
|
||||
return (int) max(0, $days);
|
||||
}
|
||||
}
|
||||
|
|
@ -98,6 +98,7 @@ class UserAbo extends Model
|
|||
'start_date' => 'datetime',
|
||||
'last_date' => 'datetime',
|
||||
'next_date' => 'datetime',
|
||||
'onetime_reminder_sent_for' => 'date',
|
||||
'cancel_date' => 'datetime',
|
||||
'user_deleted_at' => 'datetime',
|
||||
'carddata' => 'array',
|
||||
|
|
@ -121,6 +122,7 @@ class UserAbo extends Model
|
|||
'start_date',
|
||||
'last_date',
|
||||
'next_date',
|
||||
'onetime_reminder_sent_for',
|
||||
'cancel_date',
|
||||
'user_deleted_at',
|
||||
];
|
||||
|
|
@ -151,6 +153,34 @@ class UserAbo extends Model
|
|||
8 => 'success',
|
||||
];
|
||||
|
||||
/**
|
||||
* Aktive Abos (laufend): active=true und kein terminaler/inaktiver Status
|
||||
* (4 = storniert, 5 = beendet, 6 = inaktiv). Spiegelt die Definition aus
|
||||
* {@see \App\Services\Backoffice\BackofficeDashboardService}.
|
||||
*/
|
||||
public function scopeActiveAbo(\Illuminate\Database\Eloquent\Builder $query): \Illuminate\Database\Eloquent\Builder
|
||||
{
|
||||
return $query
|
||||
->where('active', true)
|
||||
->whereNotIn('status', [4, 5, 6]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schränkt die Abfrage auf Abos ein, die im angegebenen Monat/Jahr hinzugekommen
|
||||
* sind (Eingangsmonat = start_date). Bei month=0 (oder außerhalb 1–12) bleibt die
|
||||
* Abfrage unverändert.
|
||||
*/
|
||||
public function scopeAddedInMonth(\Illuminate\Database\Eloquent\Builder $query, int $year, int $month): \Illuminate\Database\Eloquent\Builder
|
||||
{
|
||||
if ($month < 1 || $month > 12) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return $query
|
||||
->whereYear('start_date', $year)
|
||||
->whereMonth('start_date', $month);
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
|
|
@ -171,6 +201,55 @@ class UserAbo extends Model
|
|||
return $this->hasMany(UserAboOrder::class);
|
||||
}
|
||||
|
||||
public function initialUserAboOrder(): ?UserAboOrder
|
||||
{
|
||||
return $this->user_abo_orders()
|
||||
->orderBy('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
public function initialShoppingOrder(): ?ShoppingOrder
|
||||
{
|
||||
return $this->initialUserAboOrder()?->shopping_order;
|
||||
}
|
||||
|
||||
public function initialShoppingPayment(): ?ShoppingPayment
|
||||
{
|
||||
return $this->initialShoppingOrder()?->shopping_payments()
|
||||
->orderBy('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
public function isPendingInitialPayment(): bool
|
||||
{
|
||||
return (int) $this->status === 1;
|
||||
}
|
||||
|
||||
public function hasInitialPaymentStatus(): bool
|
||||
{
|
||||
$payment = $this->initialShoppingPayment();
|
||||
$order = $this->initialShoppingOrder();
|
||||
|
||||
return filled($payment?->txaction)
|
||||
|| filled($payment?->status)
|
||||
|| filled($order?->txaction);
|
||||
}
|
||||
|
||||
public function hasFailedInitialPayment(): bool
|
||||
{
|
||||
$payment = $this->initialShoppingPayment();
|
||||
$order = $this->initialShoppingOrder();
|
||||
|
||||
return in_array($payment?->txaction, ['failed'], true)
|
||||
|| in_array($payment?->status, ['error', 'cancel'], true)
|
||||
|| in_array($order?->txaction, ['failed'], true);
|
||||
}
|
||||
|
||||
public function canDeletePendingInitialPayment(): bool
|
||||
{
|
||||
return $this->isPendingInitialPayment() && $this->hasFailedInitialPayment();
|
||||
}
|
||||
|
||||
public function user_abo_items()
|
||||
{
|
||||
return $this->hasMany(UserAboItem::class);
|
||||
|
|
|
|||
|
|
@ -8,9 +8,33 @@ use Carbon\Carbon;
|
|||
|
||||
class AboRepository extends BaseRepository
|
||||
{
|
||||
private const LOCK_DAYS_CHANGE = 10;
|
||||
private const DEFAULT_LOCK_DAYS_CHANGE = 10;
|
||||
|
||||
private const LOCK_DAYS_PAUSE_CANCEL = 3;
|
||||
private const DEFAULT_LOCK_DAYS_PAUSE_CANCEL = 3;
|
||||
|
||||
/**
|
||||
* Sperrfrist (Kalendertage) vor der Ausführung, ab der das Bearbeiten des Abos
|
||||
* (Intervall/Produkte) blockiert wird. Konfigurierbar über das Setting
|
||||
* `abo-lock-days-change`; Fallback auf den Default, wenn leer/0.
|
||||
*/
|
||||
public static function getLockDaysChange(): int
|
||||
{
|
||||
$days = (int) \App\Models\Setting::getContentBySlug('abo-lock-days-change');
|
||||
|
||||
return $days > 0 ? $days : self::DEFAULT_LOCK_DAYS_CHANGE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sperrfrist (Kalendertage) vor der Ausführung, ab der das Pausieren/Kündigen
|
||||
* des Abos blockiert wird. Konfigurierbar über das Setting
|
||||
* `abo-lock-days-pause-cancel`; Fallback auf den Default, wenn leer/0.
|
||||
*/
|
||||
public static function getLockDaysPauseCancel(): int
|
||||
{
|
||||
$days = (int) \App\Models\Setting::getContentBySlug('abo-lock-days-pause-cancel');
|
||||
|
||||
return $days > 0 ? $days : self::DEFAULT_LOCK_DAYS_PAUSE_CANCEL;
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
|
|
@ -59,8 +83,8 @@ class AboRepository extends BaseRepository
|
|||
// Sperre: 3 Tage vor Ausführung kann nicht mehr pausiert/gekündigt werden
|
||||
if (! $isAdminUpdate && $this->model->next_date) {
|
||||
$daysUntil = (int) now()->diffInDays(\Carbon\Carbon::parse($this->model->next_date), false);
|
||||
if ($daysUntil >= 0 && $daysUntil < self::LOCK_DAYS_PAUSE_CANCEL) {
|
||||
\Session()->flash('alert-error', __('abo.error_cancel_locked', ['days' => $daysUntil]));
|
||||
if ($daysUntil >= 0 && $daysUntil < self::getLockDaysPauseCancel()) {
|
||||
\Session()->flash('alert-error', __('abo.error_cancel_locked', ['days' => $daysUntil, 'lock' => self::getLockDaysPauseCancel()]));
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
@ -79,8 +103,8 @@ class AboRepository extends BaseRepository
|
|||
// Sperre: 3 Tage vor Ausführung kann nicht mehr pausiert werden
|
||||
if (! $isAdminUpdate && $this->model->active && ! $active && $this->model->next_date) {
|
||||
$daysUntil = (int) now()->diffInDays(\Carbon\Carbon::parse($this->model->next_date), false);
|
||||
if ($daysUntil >= 0 && $daysUntil < self::LOCK_DAYS_PAUSE_CANCEL) {
|
||||
\Session()->flash('alert-error', __('abo.error_pause_locked', ['days' => $daysUntil]));
|
||||
if ($daysUntil >= 0 && $daysUntil < self::getLockDaysPauseCancel()) {
|
||||
\Session()->flash('alert-error', __('abo.error_pause_locked', ['days' => $daysUntil, 'lock' => self::getLockDaysPauseCancel()]));
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
@ -149,26 +173,33 @@ class AboRepository extends BaseRepository
|
|||
return true;
|
||||
}
|
||||
|
||||
// Sperre: 10 Tage vor nächster Ausführung keine Änderungen mehr (Pakete werden vorgepackt)
|
||||
if ($this->model->next_date) {
|
||||
$daysUntilExecution = (int) now()->diffInDays(\Carbon\Carbon::parse($this->model->next_date), false);
|
||||
if ($daysUntilExecution >= 0 && $daysUntilExecution < self::LOCK_DAYS_CHANGE) {
|
||||
\Session()->flash('alert-error', __('abo.error_change_locked', ['days' => $daysUntilExecution]));
|
||||
// Die Change-Sperre (Liefertag/Intervall) greift NUR, wenn der Liefertag tatsächlich
|
||||
// geändert wird. Reines Pausieren/Kündigen (Intervall unverändert) unterliegt
|
||||
// ausschließlich der eigenen Sperrfrist in updateStatus() + der Mindestlaufzeit.
|
||||
$intervalChanged = (int) $data['abo_interval'] !== (int) $this->model->abo_interval;
|
||||
|
||||
if ($intervalChanged) {
|
||||
// Sperre: X Tage vor nächster Ausführung kein Liefertag-Wechsel mehr (Pakete werden vorgepackt)
|
||||
if ($this->model->next_date) {
|
||||
$daysUntilExecution = (int) now()->diffInDays(\Carbon\Carbon::parse($this->model->next_date), false);
|
||||
if ($daysUntilExecution >= 0 && $daysUntilExecution < self::getLockDaysChange()) {
|
||||
\Session()->flash('alert-error', __('abo.error_change_locked', ['days' => $daysUntilExecution, 'lock' => self::getLockDaysChange()]));
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Prüfung: Das neue berechnete Ausführungsdatum muss mindestens getLockDaysChange() Tage entfernt sein.
|
||||
// Falls next_date bereits in einem zukünftigen Monat liegt, wird das neue Datum in diesem Monat berechnet.
|
||||
$newNextDate = $this->calculateNewNextDate($data['abo_interval']);
|
||||
$daysUntilNewDate = (int) now()->diffInDays($newNextDate, false);
|
||||
if ($daysUntilNewDate < self::getLockDaysChange()) {
|
||||
\Session()->flash('alert-error', __('abo.error_abo_interval_too_soon', ['days' => $daysUntilNewDate, 'lock' => self::getLockDaysChange()]));
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Prüfung: Das neue berechnete Ausführungsdatum muss mindestens LOCK_DAYS_CHANGE Tage entfernt sein.
|
||||
// Falls next_date bereits in einem zukünftigen Monat liegt, wird das neue Datum in diesem Monat berechnet.
|
||||
$newNextDate = $this->calculateNewNextDate($data['abo_interval']);
|
||||
$daysUntilNewDate = (int) now()->diffInDays($newNextDate, false);
|
||||
if ($daysUntilNewDate < self::LOCK_DAYS_CHANGE) {
|
||||
\Session()->flash('alert-error', __('abo.error_abo_interval_too_soon', ['days' => $daysUntilNewDate]));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -120,6 +120,40 @@ class InvoiceRepository extends BaseRepository
|
|||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt eine reine VORSCHAU der Rechnungs- und Lieferschein-PDFs – ohne
|
||||
* `UserInvoice` anzulegen, ohne eine Rechnungsnummer zu verbrauchen und ohne
|
||||
* Umsatz-/Incentive-Tracking. Dient der visuellen Prüfung des Layouts (z. B. auf
|
||||
* Testsystemen, wo die reguläre Erzeugung in {@see createAndSalesVolume()}
|
||||
* bewusst deaktiviert ist).
|
||||
*
|
||||
* @return array<string, string> Storage-Pfade (disk "public") der erzeugten PDFs
|
||||
*/
|
||||
public function makePreview(): array
|
||||
{
|
||||
$invoiceDate = now()->format('d.m.Y');
|
||||
if ($payt = $this->model->getLastShoppingPaymentTransaction()) {
|
||||
$invoiceDate = $payt->created_at->format('d.m.Y');
|
||||
}
|
||||
|
||||
$this->invoice_date = $invoiceDate;
|
||||
$this->invoice_number = 'VORSCHAU-'.$this->model->id;
|
||||
$this->dir = 'invoice/preview/';
|
||||
$this->delivery_dir = 'delivery/preview/';
|
||||
$this->filename = Invoice::makeInvoiceFilename($this->invoice_number);
|
||||
$this->delivery_filename = Invoice::makeDeliveryFilename($this->invoice_number);
|
||||
$this->user_sales_volume = null; // Vorschau: kein Umsatz-Tracking, Punkte-Block entfällt
|
||||
|
||||
$this->makePDF();
|
||||
|
||||
$paths = ['invoice' => $this->dir.Invoice::makeInvoiceFilenameLocale($this->invoice_number, 'de')];
|
||||
if (! $this->model->shopping_collect_order) {
|
||||
$paths['delivery'] = $this->delivery_dir.Invoice::makeDeliveryFilenameLocale($this->invoice_number, 'de');
|
||||
}
|
||||
|
||||
return $paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt die PDFs für Rechnung und Lieferschein.
|
||||
* Das deutsche Original wird immer erstellt (Finanzamt-Anforderung).
|
||||
|
|
|
|||
391
app/Services/Abo/TeamAboOverviewService.php
Normal file
391
app/Services/Abo/TeamAboOverviewService.php
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Abo;
|
||||
|
||||
use App\Models\UserAbo;
|
||||
use App\Services\Backoffice\BackofficeDashboardService;
|
||||
use App\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Abo-fokussierte Team-Übersicht (für alle aktiven Berater, nicht nur VIP).
|
||||
*
|
||||
* Gruppiert die Downline nach Linien/Ebenen und liefert pro Ebene die Abo-Kennzahlen:
|
||||
* wer hat selbst ein Abo, wie viele Kundenabos, sowie die im gewählten Monat neu
|
||||
* hinzugekommenen Abos. Wiederverwendet die Linien-Logik aus
|
||||
* {@see BackofficeDashboardService} (keine Duplizierung der Baum-Traversierung).
|
||||
*/
|
||||
class TeamAboOverviewService
|
||||
{
|
||||
public function __construct(private BackofficeDashboardService $dashboardService) {}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* month: int,
|
||||
* year: int,
|
||||
* lines: array<int, array<string, mixed>>,
|
||||
* totals: array<string, mixed>
|
||||
* }
|
||||
*/
|
||||
public function linesOverview(User $user, int $month, int $year): array
|
||||
{
|
||||
$lineBuckets = $this->dashboardService->lineBuckets($user->id);
|
||||
|
||||
$lines = [];
|
||||
$totals = $this->emptyLine(0);
|
||||
$totals['label'] = __('abo.team_total');
|
||||
|
||||
foreach ($lineBuckets as $line => $users) {
|
||||
$row = $this->buildLineRow((int) $line, $users, $month, $year);
|
||||
$lines[$line] = $row;
|
||||
$totals = $this->addToTotals($totals, $row);
|
||||
}
|
||||
|
||||
return [
|
||||
'month' => $month,
|
||||
'year' => $year,
|
||||
'lines' => $lines,
|
||||
'totals' => $totals,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Leichtgewichtige Kennzahlen für das Dashboard (ohne Punkte-/Mitglieder-Details).
|
||||
*
|
||||
* @return array{team_total_abos: int, team_partner_abos: int, team_customer_abos: int, shop_1000: int}
|
||||
*/
|
||||
public function teamKpis(User $user, int $month, int $year): array
|
||||
{
|
||||
$teamUserIds = $this->dashboardService->teamUserIds($user->id);
|
||||
|
||||
if ($teamUserIds === []) {
|
||||
return [
|
||||
'team_total_abos' => 0,
|
||||
'team_partner_abos' => 0,
|
||||
'team_customer_abos' => 0,
|
||||
'shop_1000' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$partnerAbos = UserAbo::query()->activeAbo()->where('is_for', 'me')->whereIn('user_id', $teamUserIds)->count();
|
||||
$customerAbos = UserAbo::query()->activeAbo()->where('is_for', 'ot')->whereIn('member_id', $teamUserIds)->count();
|
||||
|
||||
return [
|
||||
'team_total_abos' => $partnerAbos + $customerAbos,
|
||||
'team_partner_abos' => $partnerAbos,
|
||||
'team_customer_abos' => $customerAbos,
|
||||
'shop_1000' => $this->dashboardService->shop1000Count($teamUserIds, $month, $year),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Monatliches Abo-Wachstum des Teams für ein Jahr: je Monat die Anzahl neu
|
||||
* hinzugekommener (aktiver) Eigen- und Kunden-Abos anhand des Eingangsmonats
|
||||
* (start_date). Dient der separaten Wachstums-Tabelle unter der Bestandsübersicht.
|
||||
*
|
||||
* @return array{
|
||||
* year: int,
|
||||
* months: array<int, array{month: int, label: string, own_new: int, customer_new: int, total_new: int}>,
|
||||
* totals: array{own_new: int, customer_new: int, total_new: int}
|
||||
* }
|
||||
*/
|
||||
public function monthlyGrowth(User $user, int $year): array
|
||||
{
|
||||
$teamUserIds = $this->dashboardService->teamUserIds($user->id);
|
||||
$monthNames = \App\Services\HTMLHelper::getTransMonths();
|
||||
|
||||
$ownByMonth = $teamUserIds === [] ? [] : $this->newAbosByMonth($teamUserIds, 'user_id', 'me', $year);
|
||||
$customerByMonth = $teamUserIds === [] ? [] : $this->newAbosByMonth($teamUserIds, 'member_id', 'ot', $year);
|
||||
|
||||
$months = [];
|
||||
$totals = ['own_new' => 0, 'customer_new' => 0, 'total_new' => 0];
|
||||
|
||||
for ($month = 1; $month <= 12; $month++) {
|
||||
$own = $ownByMonth[$month] ?? 0;
|
||||
$customer = $customerByMonth[$month] ?? 0;
|
||||
|
||||
$months[] = [
|
||||
'month' => $month,
|
||||
'label' => $monthNames[$month] ?? (string) $month,
|
||||
'own_new' => $own,
|
||||
'customer_new' => $customer,
|
||||
'total_new' => $own + $customer,
|
||||
];
|
||||
|
||||
$totals['own_new'] += $own;
|
||||
$totals['customer_new'] += $customer;
|
||||
$totals['total_new'] += $own + $customer;
|
||||
}
|
||||
|
||||
return [
|
||||
'year' => $year,
|
||||
'months' => $months,
|
||||
'totals' => $totals,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Zählt neue (aktive) Abos je Monat eines Jahres anhand start_date.
|
||||
* DB-agnostisch (kein MONTH()/strftime): rohe Datumswerte werden in PHP gruppiert.
|
||||
*
|
||||
* @param int[] $userIds
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function newAbosByMonth(array $userIds, string $userColumn, string $isFor, int $year): array
|
||||
{
|
||||
$dates = UserAbo::query()
|
||||
->activeAbo()
|
||||
->where('is_for', $isFor)
|
||||
->whereIn($userColumn, $userIds)
|
||||
->whereYear('start_date', $year)
|
||||
->toBase()
|
||||
->pluck('start_date');
|
||||
|
||||
$byMonth = [];
|
||||
foreach ($dates as $date) {
|
||||
if (! $date) {
|
||||
continue;
|
||||
}
|
||||
$month = (int) Carbon::parse($date)->month;
|
||||
$byMonth[$month] = ($byMonth[$month] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return $byMonth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abo-Zusammenfassung eines einzelnen Team-Mitglieds für das Detail-Modal.
|
||||
* Liefert null, wenn das Mitglied nicht in der Downline des Owners liegt.
|
||||
*
|
||||
* @return array{
|
||||
* name: string,
|
||||
* email: string,
|
||||
* own_abo: array<string, mixed>|null,
|
||||
* customer_abos: array{count: int, total_points: float, items: array<int, array<string, mixed>>}
|
||||
* }|null
|
||||
*/
|
||||
public function memberAboSummary(User $owner, int $memberId): ?array
|
||||
{
|
||||
if (! \App\Services\AboHelper::isUserInTeam($owner->id, $memberId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$member = User::with('account')->find($memberId);
|
||||
|
||||
if (! $member) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$ownAbo = UserAbo::query()
|
||||
->activeAbo()
|
||||
->where('is_for', 'me')
|
||||
->where('user_id', $memberId)
|
||||
->with(['user_abo_items.product', 'user_abo_orders'])
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
$customerAbos = UserAbo::query()
|
||||
->activeAbo()
|
||||
->where('is_for', 'ot')
|
||||
->where('member_id', $memberId)
|
||||
->with(['user_abo_items.product', 'user_abo_orders'])
|
||||
->orderBy('start_date')
|
||||
->get();
|
||||
|
||||
return [
|
||||
'name' => $this->memberName($member),
|
||||
'email' => $member->email ?? '',
|
||||
'own_abo' => $ownAbo ? $this->aboSummary($ownAbo) : null,
|
||||
'customer_abos' => [
|
||||
'count' => $customerAbos->count(),
|
||||
'total_points' => (float) $customerAbos->sum(fn (UserAbo $abo) => $abo->getTotalPoints()),
|
||||
'items' => $customerAbos->map(fn (UserAbo $abo) => $this->aboSummary($abo))->all(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{points: float, start_date: string, next_date: string, executions: int, amount: string, interval: int}
|
||||
*/
|
||||
private function aboSummary(UserAbo $abo): array
|
||||
{
|
||||
return [
|
||||
'points' => (float) $abo->getTotalPoints(),
|
||||
'start_date' => $abo->start_date,
|
||||
'next_date' => $abo->next_date,
|
||||
'executions' => $abo->getCountPaidOrders(),
|
||||
'amount' => $abo->getFormattedAmount(),
|
||||
'interval' => (int) $abo->abo_interval,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \Illuminate\Support\Collection<int, \App\User> $users
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildLineRow(int $line, Collection $users, int $month, int $year): array
|
||||
{
|
||||
$userIds = $users->pluck('id')->map(fn ($id) => (int) $id)->all();
|
||||
|
||||
if ($userIds === []) {
|
||||
return $this->emptyLine($line);
|
||||
}
|
||||
|
||||
$users->loadMissing('user_level');
|
||||
|
||||
$ownAbos = $this->ownAbosByUser($userIds);
|
||||
$customerAbos = $this->customerAbosByMember($userIds);
|
||||
|
||||
$members = [];
|
||||
$membersWithOwnAbo = 0;
|
||||
$ownAbosCount = 0;
|
||||
$ownAboPoints = 0.0;
|
||||
$customerAbosCount = 0;
|
||||
$customerAboPoints = 0.0;
|
||||
|
||||
foreach ($users as $teamUser) {
|
||||
$uid = (int) $teamUser->id;
|
||||
$ownAbo = $ownAbos->get($uid);
|
||||
$custList = $customerAbos->get($uid, collect());
|
||||
|
||||
$hasOwnAbo = $ownAbo !== null;
|
||||
$memberOwnPoints = $hasOwnAbo ? (float) $ownAbo->getTotalPoints() : 0.0;
|
||||
$memberCustomerCount = $custList->count();
|
||||
$memberCustomerPoints = (float) $custList->sum(fn (UserAbo $abo) => $abo->getTotalPoints());
|
||||
|
||||
if ($hasOwnAbo) {
|
||||
$membersWithOwnAbo++;
|
||||
$ownAbosCount++;
|
||||
$ownAboPoints += $memberOwnPoints;
|
||||
}
|
||||
$customerAbosCount += $memberCustomerCount;
|
||||
$customerAboPoints += $memberCustomerPoints;
|
||||
|
||||
$members[] = [
|
||||
'user_id' => $uid,
|
||||
'name' => $this->memberName($teamUser),
|
||||
'career_level' => $teamUser->user_level?->getLang('name') ?? '',
|
||||
'has_own_abo' => $hasOwnAbo,
|
||||
'own_abo_points' => $memberOwnPoints,
|
||||
'customer_abo_count' => $memberCustomerCount,
|
||||
'customer_abo_points' => $memberCustomerPoints,
|
||||
'total_abos' => ($hasOwnAbo ? 1 : 0) + $memberCustomerCount,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'line' => $line,
|
||||
'label' => __('abo.team_line', ['line' => $line]),
|
||||
'consultants' => $users->count(),
|
||||
'members_total' => $users->count(),
|
||||
'members_with_own_abo' => $membersWithOwnAbo,
|
||||
'own_abos' => $ownAbosCount,
|
||||
'own_abos_new' => $this->newAboCount($userIds, 'user_id', 'me', $month, $year),
|
||||
'own_abo_points' => $ownAboPoints,
|
||||
'customer_abos' => $customerAbosCount,
|
||||
'customer_abos_new' => $this->newAboCount($userIds, 'member_id', 'ot', $month, $year),
|
||||
'customer_abo_points' => $customerAboPoints,
|
||||
'total_abos' => $ownAbosCount + $customerAbosCount,
|
||||
'members' => $members,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Eigene (me) aktive Abos je Berater, indiziert nach user_id (ein Abo pro Berater).
|
||||
*
|
||||
* @param int[] $userIds
|
||||
* @return \Illuminate\Support\Collection<int, \App\Models\UserAbo>
|
||||
*/
|
||||
private function ownAbosByUser(array $userIds): Collection
|
||||
{
|
||||
return UserAbo::query()
|
||||
->activeAbo()
|
||||
->where('is_for', 'me')
|
||||
->whereIn('user_id', $userIds)
|
||||
->with('user_abo_items.product')
|
||||
->get()
|
||||
->keyBy(fn (UserAbo $abo) => (int) $abo->user_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kundenabos (ot) je Berater, gruppiert nach member_id.
|
||||
*
|
||||
* @param int[] $userIds
|
||||
* @return \Illuminate\Support\Collection<int, \Illuminate\Support\Collection<int, \App\Models\UserAbo>>
|
||||
*/
|
||||
private function customerAbosByMember(array $userIds): Collection
|
||||
{
|
||||
return UserAbo::query()
|
||||
->activeAbo()
|
||||
->where('is_for', 'ot')
|
||||
->whereIn('member_id', $userIds)
|
||||
->with('user_abo_items.product')
|
||||
->get()
|
||||
->groupBy(fn (UserAbo $abo) => (int) $abo->member_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[] $userIds
|
||||
*/
|
||||
private function newAboCount(array $userIds, string $userColumn, string $isFor, int $month, int $year): int
|
||||
{
|
||||
$startDate = Carbon::create($year, $month, 1)->startOfMonth();
|
||||
$endDate = Carbon::create($year, $month, 1)->endOfMonth();
|
||||
|
||||
return UserAbo::query()
|
||||
->activeAbo()
|
||||
->whereIn($userColumn, $userIds)
|
||||
->where('is_for', $isFor)
|
||||
->whereBetween('start_date', [$startDate, $endDate])
|
||||
->count();
|
||||
}
|
||||
|
||||
private function memberName(User $user): string
|
||||
{
|
||||
if ($user->account) {
|
||||
$name = trim($user->account->first_name.' '.$user->account->last_name);
|
||||
if ($name !== '') {
|
||||
return $name;
|
||||
}
|
||||
}
|
||||
|
||||
return $user->email ?? ('#'.$user->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function emptyLine(int $line): array
|
||||
{
|
||||
return [
|
||||
'line' => $line,
|
||||
'label' => $line > 0 ? __('abo.team_line', ['line' => $line]) : __('abo.team_total'),
|
||||
'consultants' => 0,
|
||||
'members_total' => 0,
|
||||
'members_with_own_abo' => 0,
|
||||
'own_abos' => 0,
|
||||
'own_abos_new' => 0,
|
||||
'own_abo_points' => 0.0,
|
||||
'customer_abos' => 0,
|
||||
'customer_abos_new' => 0,
|
||||
'customer_abo_points' => 0.0,
|
||||
'total_abos' => 0,
|
||||
'members' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $totals
|
||||
* @param array<string, mixed> $row
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function addToTotals(array $totals, array $row): array
|
||||
{
|
||||
foreach (['consultants', 'members_total', 'members_with_own_abo', 'own_abos', 'own_abos_new', 'own_abo_points', 'customer_abos', 'customer_abos_new', 'customer_abo_points', 'total_abos'] as $key) {
|
||||
$totals[$key] += $row[$key];
|
||||
}
|
||||
|
||||
return $totals;
|
||||
}
|
||||
}
|
||||
|
|
@ -43,7 +43,7 @@ class AboHelper
|
|||
{
|
||||
$user = $user ? $user : \Auth::user();
|
||||
|
||||
return UserAbo::where('user_id', $user->id)->where('is_for', 'me')->where('status', '>', 1)->first() === null ? false : true;
|
||||
return UserAbo::where('user_id', $user->id)->where('is_for', 'me')->where('status', '>=', 1)->first() === null ? false : true;
|
||||
}
|
||||
|
||||
public static function memberHasAbo(ShoppingUser $shopping_user)
|
||||
|
|
@ -52,12 +52,12 @@ class AboHelper
|
|||
return false;
|
||||
}
|
||||
|
||||
return UserAbo::where('email', $shopping_user->billing_email)->where('is_for', 'ot')->where('status', '>', 1)->first() === null ? false : true;
|
||||
return UserAbo::where('email', $shopping_user->billing_email)->where('is_for', 'ot')->where('status', '>=', 1)->first() === null ? false : true;
|
||||
}
|
||||
|
||||
public static function hasAboByEmail($email)
|
||||
{
|
||||
return UserAbo::where('email', $email)->where('status', '>', 1)->first() === null ? false : true;
|
||||
return UserAbo::where('email', $email)->where('status', '>=', 1)->first() === null ? false : true;
|
||||
}
|
||||
|
||||
public static function setAboStatus(ShoppingOrder $shopping_order, $status, $paid = false)
|
||||
|
|
@ -115,6 +115,23 @@ class AboHelper
|
|||
return ! self::canCancelAbo($user_abo, $view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob das Bearbeiten des Abos (Liefertag/Intervall) aktuell durch die
|
||||
* Sperrfrist vor der Ausführung blockiert ist. Admins umgehen die Sperre.
|
||||
* Spiegelt die serverseitige Prüfung in {@see \App\Repositories\AboRepository::validate()}
|
||||
* für die UI (Feld/Button deaktivieren).
|
||||
*/
|
||||
public static function isAboChangeLocked(UserAbo $userAbo, string $view = 'user'): bool
|
||||
{
|
||||
if ($view === 'admin' || ! $userAbo->next_date) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$daysUntil = (int) Carbon::now()->diffInDays(Carbon::parse($userAbo->next_date), false);
|
||||
|
||||
return $daysUntil >= 0 && $daysUntil < \App\Repositories\AboRepository::getLockDaysChange();
|
||||
}
|
||||
|
||||
public static function canEditAbo($user_abo, $view = 'user')
|
||||
{
|
||||
if ($view === 'portal') {
|
||||
|
|
@ -157,10 +174,10 @@ class AboHelper
|
|||
public static function getAboTypeBadge($abo_type)
|
||||
{
|
||||
if ($abo_type === 'base') {
|
||||
return '<span class="badge badge-pill badge-warning"><i class="fas fa-star"></i> ' . __('abo.' . $abo_type) . '</span></a>';
|
||||
return '<span class="badge badge-pill badge-warning"><i class="fas fa-star"></i> '.__('abo.'.$abo_type).'</span></a>';
|
||||
}
|
||||
if ($abo_type === 'upgrade') {
|
||||
return '<span class="badge badge-pill badge-info"><i class="far fa-star"></i> ' . __('abo.' . $abo_type) . '</span></a>';
|
||||
return '<span class="badge badge-pill badge-info"><i class="far fa-star"></i> '.__('abo.'.$abo_type).'</span></a>';
|
||||
}
|
||||
|
||||
return '';
|
||||
|
|
@ -210,24 +227,14 @@ class AboHelper
|
|||
}
|
||||
|
||||
/**
|
||||
* Sichtbarkeit des Einmalprodukte-Features während der Live-Abstimmung.
|
||||
*
|
||||
* Temporäre Einschränkung: Das Feature wird vorerst nur VIP-Usern (Admins)
|
||||
* im Sales Center angezeigt. Das Endkunden-Portal läuft über den
|
||||
* `customers`-Guard und hat keinen VIP-User auf dem `user`-Guard, daher
|
||||
* bleibt das Feature dort vollständig ausgeblendet. Nach Abschluss der
|
||||
* Abstimmung kann diese Methode wieder auf reines isOneTimeWindowOpen()
|
||||
* zurückgesetzt werden, um das Feature für alle freizuschalten.
|
||||
* Sichtbarkeit des Einmalprodukte-Features. Maßgeblich ist allein das
|
||||
* konfigurierte Zeitfenster vor der Ausführung (Sales Center und Portal
|
||||
* gleichermaßen). Das frühere Review-Gate (nur VIP, Portal ausgeblendet)
|
||||
* wurde nach Abschluss der Abstimmung aufgehoben.
|
||||
*/
|
||||
public static function isOneTimeFeatureVisible(UserAbo $userAbo): bool
|
||||
{
|
||||
/* if (! self::isOneTimeWindowOpen($userAbo)) {
|
||||
return false;
|
||||
}*/
|
||||
|
||||
$user = \Auth::guard('user')->user();
|
||||
|
||||
return $user instanceof User && $user->isVIP();
|
||||
return self::isOneTimeWindowOpen($userAbo);
|
||||
}
|
||||
|
||||
public static function getFirstAboDate($date, $abo_interval)
|
||||
|
|
@ -412,7 +419,7 @@ class AboHelper
|
|||
{
|
||||
$ret = [];
|
||||
foreach (self::$txaction_filter_text as $key => $val) {
|
||||
$ret[$key] = trans('payment.' . $val);
|
||||
$ret[$key] = trans('payment.'.$val);
|
||||
}
|
||||
|
||||
return $ret;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue