Abo Einmalprodukte: Review-Gate (VIP), Verbindlichkeit & Summen-Layout

- Live-Review-Gate: Einmalprodukte nur fuer VIP im Sales Center sichtbar,
  Portal ausgeblendet (AboHelper::isOneTimeFeatureVisible + Gates in Controllern)
- Nur verbindlich bestaetigte Einmal-Artikel fliessen in die Lieferung;
  Service-Helfer confirmedItems/pendingItems/pendingGross
- Footer-Layout der Einmalprodukt-Liste: bestaetigte Summe + Gesamtbetrag,
  Trennstrich, offener Betrag und neue Gesamtsumme (dunkelgruen)
- Uebersetzungen DE/EN/ES/FR (onetime_new_total u.a.), Tests angepasst/ergaenzt

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Kevin 2026-06-08 14:59:22 +00:00
parent 2269ce031f
commit 8288ea59ac
16 changed files with 356 additions and 46 deletions

View file

@ -11,6 +11,7 @@ use App\Models\UserAbo;
use App\Models\UserCredit;
use App\Models\UserLevel;
use App\Models\UserSalesVolume;
use App\Services\AboHelper;
use App\Services\BusinessPlan\TreeCalcBot;
use App\Services\BusinessPlan\TreeCalcBotOptimized;
use App\Services\DhlModalService;
@ -172,6 +173,7 @@ class ModalController extends Controller
}
if ($data['action'] === 'abo-add-onetime') {
$user_abo = UserAbo::find($data['id']);
abort_unless($user_abo && AboHelper::isOneTimeFeatureVisible($user_abo), 403);
$ret = view('user.abo.modal_abo_onetime_products', compact('data', 'user_abo'))->render();
}

View file

@ -73,7 +73,7 @@ class AboController extends Controller
AboOrderCart::makeOrderYard($user_abo);
$baseCompCount = Yard::instance(AboOrderCart::INSTANCE)->getNumComp();
$oneTimeWindowOpen = AboHelper::isOneTimeWindowOpen($user_abo);
$oneTimeWindowOpen = AboHelper::isOneTimeFeatureVisible($user_abo);
if ($oneTimeWindowOpen) {
AboOrderCart::addOneTimeItemsToYard($user_abo);
AboOrderCart::checkNumOfCompProducts($user_abo);
@ -211,7 +211,7 @@ class AboController extends Controller
AboOrderCart::initYard($user_abo);
AboOrderCart::makeOrderYard($user_abo);
$baseCompCount = Yard::instance(AboOrderCart::INSTANCE)->getNumComp();
if (AboHelper::isOneTimeWindowOpen($user_abo)) {
if (AboHelper::isOneTimeFeatureVisible($user_abo)) {
AboOrderCart::addOneTimeItemsToYard($user_abo->fresh());
}
AboOrderCart::checkNumOfCompProducts($user_abo);
@ -221,7 +221,7 @@ class AboController extends Controller
$html_cart = view('admin.abo._order_abo_show', [
'user_abo' => $user_abo,
'error_message' => $error_message,
'split_mode' => AboHelper::isOneTimeWindowOpen($user_abo),
'split_mode' => AboHelper::isOneTimeFeatureVisible($user_abo),
'summary' => $summary,
'add_only_mode' => $isAddOnlyMode,
])->render();
@ -325,6 +325,7 @@ class AboController extends Controller
if ($data['action'] === 'abo-add-onetime') {
$user_abo = UserAbo::find($data['id']);
$this->checkPortalPermission($user_abo);
abort_unless(AboHelper::isOneTimeFeatureVisible($user_abo), 403);
$ret = view('user.abo.modal_abo_onetime_products', compact('data', 'user_abo'))->render();
}
if ($data['action'] === 'abo_update_settings') {
@ -352,7 +353,7 @@ class AboController extends Controller
$this->checkPortalPermission($user_abo);
$isAddOnlyMode = AboHelper::isAddOnlyMode($user_abo, $view);
if (! AboHelper::isOneTimeWindowOpen($user_abo)) {
if (! AboHelper::isOneTimeFeatureVisible($user_abo)) {
return response()->json([
'response' => false,
'message' => __('abo.onetime_window_closed'),
@ -404,6 +405,8 @@ class AboController extends Controller
$user_abo = UserAbo::findOrFail($user_abo_id);
$this->checkPortalPermission($user_abo);
abort_unless(AboHelper::isOneTimeFeatureVisible($user_abo), 403);
AboOrderCart::initYard($user_abo);
$query = Product::select('products.*')

View file

@ -89,7 +89,7 @@ class AboController extends Controller
AboOrderCart::makeOrderYard($user_abo);
$baseCompCount = Yard::instance(AboOrderCart::INSTANCE)->getNumComp();
$oneTimeWindowOpen = AboHelper::isOneTimeWindowOpen($user_abo);
$oneTimeWindowOpen = AboHelper::isOneTimeFeatureVisible($user_abo);
if ($oneTimeWindowOpen) {
AboOrderCart::addOneTimeItemsToYard($user_abo);
AboOrderCart::checkNumOfCompProducts($user_abo);
@ -233,7 +233,7 @@ class AboController extends Controller
AboOrderCart::initYard($user_abo);
AboOrderCart::makeOrderYard($user_abo); // reCalculateShippingPrice
$baseCompCount = Yard::instance(AboOrderCart::INSTANCE)->getNumComp();
if (AboHelper::isOneTimeWindowOpen($user_abo)) {
if (AboHelper::isOneTimeFeatureVisible($user_abo)) {
AboOrderCart::addOneTimeItemsToYard($user_abo->fresh());
}
AboOrderCart::checkNumOfCompProducts($user_abo); // after reCalculateShippingPrice check it and remove or add comp product
@ -246,7 +246,7 @@ class AboController extends Controller
$html_cart = view('admin.abo._order_abo_show', [
'user_abo' => $user_abo,
'error_message' => $error_message,
'split_mode' => AboHelper::isOneTimeWindowOpen($user_abo),
'split_mode' => AboHelper::isOneTimeFeatureVisible($user_abo),
'summary' => $summary,
'add_only_mode' => $isAddOnlyMode,
])->render();
@ -273,7 +273,7 @@ class AboController extends Controller
$editView = \Auth::user()?->isAdmin() ? 'admin' : $view;
$isAddOnlyMode = AboHelper::isAddOnlyMode($user_abo, $editView);
if (! AboHelper::isOneTimeWindowOpen($user_abo)) {
if (! AboHelper::isOneTimeFeatureVisible($user_abo)) {
return response()->json([
'response' => false,
'message' => __('abo.onetime_window_closed'),
@ -448,6 +448,8 @@ class AboController extends Controller
{
$user_abo = UserAbo::findOrFail($user_abo_id);
abort_unless(AboHelper::isOneTimeFeatureVisible($user_abo), 403);
AboOrderCart::initYard($user_abo);
$show_on_ids = $user_abo->is_for === 'me' ? ['2'] : ['3'];

View file

@ -157,10 +157,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 '';
@ -209,6 +209,27 @@ class AboHelper
return $daysUntilExecution <= self::getOneTimeWindowDays();
}
/**
* 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.
*/
public static function isOneTimeFeatureVisible(UserAbo $userAbo): bool
{
/* if (! self::isOneTimeWindowOpen($userAbo)) {
return false;
}*/
$user = \Auth::guard('user')->user();
return $user instanceof User && $user->isVIP();
}
public static function getFirstAboDate($date, $abo_interval)
{
$reference = Carbon::parse($date)->startOfDay();
@ -391,7 +412,7 @@ class AboHelper
{
$ret = [];
foreach (self::$txaction_filter_text as $key => $val) {
$ret[$key] = trans('payment.'.$val);
$ret[$key] = trans('payment.' . $val);
}
return $ret;

View file

@ -158,6 +158,40 @@ class AboOneTimeService
return null;
}
/**
* Verbindlich bestätigte Einmal-Artikel (werden mit der nächsten Lieferung versandt).
*
* @return \Illuminate\Support\Collection<int, UserAboOneTimeItem>
*/
public static function confirmedItems(UserAbo $userAbo): \Illuminate\Support\Collection
{
return $userAbo->one_time_items()
->with('product')
->get()
->filter(fn (UserAboOneTimeItem $item): bool => $item->isConfirmed())
->values();
}
/**
* Noch nicht bestätigte Einmal-Artikel (Entwurf, nicht verbindlich).
*
* @return \Illuminate\Support\Collection<int, UserAboOneTimeItem>
*/
public static function pendingItems(UserAbo $userAbo): \Illuminate\Support\Collection
{
return $userAbo->one_time_items()
->with('product')
->get()
->reject(fn (UserAboOneTimeItem $item): bool => $item->isConfirmed())
->values();
}
public static function pendingGross(UserAbo $userAbo): float
{
return round(self::pendingItems($userAbo)
->sum(fn (UserAboOneTimeItem $item): float => (float) $item->price * (int) $item->qty), 2);
}
public static function hasUnconfirmedChanges(UserAbo $userAbo): bool
{
return UserAboOneTimeItem::withTrashed()

View file

@ -246,7 +246,14 @@ class AboOrderCart
$yard = Yard::instance(self::INSTANCE);
foreach ($user_abo->one_time_items()->get() as $item) {
// Nur verbindlich bestätigte Einmal-Artikel fließen in die nächste Lieferung
// (Snapshot-Menge == bestätigte Menge). Unbestätigte Entwürfe bleiben außen vor.
$confirmedItems = $user_abo->one_time_items()
->whereNotNull('confirmed_at')
->whereColumn('confirmed_qty', 'qty')
->get();
foreach ($confirmedItems as $item) {
$product = Product::find($item->product_id);
if (! $product) {
continue;