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

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