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:
Kevin 2026-06-23 13:38:11 +00:00
parent 9f996a7f1b
commit 65b867f8ee
96 changed files with 4154 additions and 1189 deletions

View 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';
}
}

View 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';
}
}

View 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;
}
}

View file

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

View file

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

View file

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

View file

@ -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,

View file

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

View 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);
}
}

View file

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

View file

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

View file

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

View 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;
}
}

View file

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