From 2269ce031ffbcd9f3351d8244ad964ae99c55571 Mon Sep 17 00:00:00 2001 From: Kevin Date: Fri, 5 Jun 2026 15:28:08 +0000 Subject: [PATCH] =?UTF-8?q?Abo=20Einmalprodukte=20und=20Best=C3=A4tigung?= =?UTF-8?q?=20abschlie=C3=9Fen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- app/Cron/UserMakeOrder.php | 6 +- app/Http/Controllers/ModalController.php | 4 + app/Http/Controllers/Portal/AboController.php | 188 ++++++++- .../Controllers/Portal/OrderController.php | 7 + app/Http/Controllers/User/AboController.php | 195 ++++++++- app/Http/Controllers/User/OrderController.php | 70 +++- app/Http/Controllers/Web/CardController.php | 22 +- .../Requests/Abo/AboOneTimeItemRequest.php | 42 ++ app/Models/ShoppingOrderItem.php | 45 +- app/Models/UserAbo.php | 6 + app/Models/UserAboItem.php | 82 ++-- app/Models/UserAboOneTimeItem.php | 101 +++++ app/Services/AboHelper.php | 41 ++ app/Services/AboOneTimeService.php | 181 ++++++++ app/Services/AboOrderCart.php | 280 +++++++++++-- app/Services/Yard.php | 33 +- .../factories/UserAboOneTimeItemFactory.php | 32 ++ ...9_add_french_locale_to_trans_languages.php | 9 + ...0_create_user_abo_one_time_items_table.php | 45 ++ ...bo_addon_to_shopping_order_items_table.php | 22 + ...state_to_user_abo_one_time_items_table.php | 29 ++ .../ENTWICKLUNGSPLAN-ABO-EINMALPRODUKTE.md | 387 ++++++++++++++++++ public/js/iq-abo-onetime.js | 297 ++++++++++++++ public/js/iq-modal-cart.js | 114 ++++-- public/js/iq-shopping-cart.js | 38 +- resources/lang/de/abo.php | 35 +- resources/lang/de/msg.php | 1 + resources/lang/de/order.php | 1 + resources/lang/en/abo.php | 35 +- resources/lang/en/msg.php | 1 + resources/lang/en/order.php | 1 + resources/lang/es/abo.php | 35 +- resources/lang/es/msg.php | 80 ++-- resources/lang/es/order.php | 1 + resources/lang/fr/abo.php | 35 +- resources/lang/fr/msg.php | 1 + resources/lang/fr/order.php | 1 + .../admin/abo/_confirm_add_modal.blade.php | 56 +++ .../views/admin/abo/_order_abo.blade.php | 4 +- .../views/admin/abo/_order_abo_show.blade.php | 35 +- .../abo/_order_combined_summary.blade.php | 50 +++ .../views/admin/abo/_order_onetime.blade.php | 23 ++ .../admin/abo/_order_onetime_show.blade.php | 105 +++++ resources/views/admin/abo/detail.blade.php | 47 +-- .../views/admin/settings/index.blade.php | 6 + resources/views/portal/abo/my_abo.blade.php | 128 +++--- resources/views/user/abo/detail.blade.php | 93 ++--- .../abo/modal_abo_onetime_products.blade.php | 57 +++ .../views/user/order/comp_product.blade.php | 48 ++- .../views/user/order/yard_view_form.blade.php | 61 ++- routes/domains/crm.php | 2 + routes/domains/portal.php | 2 + tests/Feature/AboOneTimeServiceTest.php | 180 ++++++++ tests/Feature/AboOneTimeViewTest.php | 201 +++++++++ tests/Feature/AboOneTimeWindowTest.php | 120 ++++++ tests/Feature/CartMaxWeightTest.php | 232 +++++++++++ tests/Pest.php | 65 +++ 57 files changed, 3647 insertions(+), 371 deletions(-) create mode 100644 app/Http/Requests/Abo/AboOneTimeItemRequest.php create mode 100644 app/Models/UserAboOneTimeItem.php create mode 100644 app/Services/AboOneTimeService.php create mode 100644 database/factories/UserAboOneTimeItemFactory.php create mode 100644 database/migrations/2026_06_05_120000_create_user_abo_one_time_items_table.php create mode 100644 database/migrations/2026_06_05_120100_add_is_abo_addon_to_shopping_order_items_table.php create mode 100644 database/migrations/2026_06_05_164339_add_confirmation_state_to_user_abo_one_time_items_table.php create mode 100644 dev/2026-06-05/ENTWICKLUNGSPLAN-ABO-EINMALPRODUKTE.md create mode 100644 public/js/iq-abo-onetime.js create mode 100644 resources/views/admin/abo/_confirm_add_modal.blade.php create mode 100644 resources/views/admin/abo/_order_combined_summary.blade.php create mode 100644 resources/views/admin/abo/_order_onetime.blade.php create mode 100644 resources/views/admin/abo/_order_onetime_show.blade.php create mode 100644 resources/views/user/abo/modal_abo_onetime_products.blade.php create mode 100644 tests/Feature/AboOneTimeServiceTest.php create mode 100644 tests/Feature/AboOneTimeViewTest.php create mode 100644 tests/Feature/AboOneTimeWindowTest.php create mode 100644 tests/Feature/CartMaxWeightTest.php diff --git a/app/Cron/UserMakeOrder.php b/app/Cron/UserMakeOrder.php index 6574d52..685826a 100644 --- a/app/Cron/UserMakeOrder.php +++ b/app/Cron/UserMakeOrder.php @@ -163,13 +163,13 @@ class UserMakeOrder // WICHTIG: Yard komplett leeren vor jedem Abo, um sicherzustellen, dass keine Produkte // aus vorherigen Abos im Cart bleiben - Yard::instance('shopping')->destroy(); + Yard::instance(AboOrderCart::INSTANCE)->destroy(); // initYard akzeptiert nur einen Parameter (user_abo) AboOrderCart::initYard($this->userAbo); // Nochmalige Sicherheitsprüfung: Yard sollte leer sein - $yardBefore = Yard::instance('shopping'); + $yardBefore = Yard::instance(AboOrderCart::INSTANCE); $itemsBefore = $yardBefore->content(); if ($itemsBefore->count() > 0) { Log::warning('UserMakeOrder: Yard war nicht leer nach initYard für Abo ID: '.$this->userAbo->id.', Items: '.$itemsBefore->count()); @@ -179,7 +179,7 @@ class UserMakeOrder // hier wird die Bestellung erstellt inkl aktueller Preise AboOrderCart::makeOrderYard($this->userAbo); - $yard = Yard::instance('shopping'); + $yard = Yard::instance(AboOrderCart::INSTANCE); // Debug: Logge welche Produkte im Cart sind $items = $yard->content(); diff --git a/app/Http/Controllers/ModalController.php b/app/Http/Controllers/ModalController.php index 4e6c7d5..d2bc34a 100644 --- a/app/Http/Controllers/ModalController.php +++ b/app/Http/Controllers/ModalController.php @@ -170,6 +170,10 @@ class ModalController extends Controller $user_abo = UserAbo::find($data['id']); $ret = view('user.abo.modal_abo_show_products', compact('data', 'user_abo'))->render(); } + if ($data['action'] === 'abo-add-onetime') { + $user_abo = UserAbo::find($data['id']); + $ret = view('user.abo.modal_abo_onetime_products', compact('data', 'user_abo'))->render(); + } if ($data['action'] === 'create-dhl-shipment') { $this->authorizeDhlShipmentModal(); diff --git a/app/Http/Controllers/Portal/AboController.php b/app/Http/Controllers/Portal/AboController.php index bf00b00..8769456 100644 --- a/app/Http/Controllers/Portal/AboController.php +++ b/app/Http/Controllers/Portal/AboController.php @@ -3,14 +3,17 @@ namespace App\Http\Controllers\Portal; use App\Http\Controllers\Controller; +use App\Http\Requests\Abo\AboOneTimeItemRequest; use App\Models\Product; use App\Models\ShoppingInstance; use App\Models\ShoppingUser; use App\Models\UserAbo; use App\Models\UserAboItem; +use App\Models\UserAboOneTimeItem; use App\Repositories\AboRepository; use App\Services\AboHelper; use App\Services\AboItemHistoryService; +use App\Services\AboOneTimeService; use App\Services\AboOrderCart; use App\Services\Shop; use App\Services\UserService; @@ -68,6 +71,14 @@ class AboController extends Controller AboOrderCart::initYard($user_abo); $customer_detail = AboOrderCart::getCustomerDetail(); AboOrderCart::makeOrderYard($user_abo); + $baseCompCount = Yard::instance(AboOrderCart::INSTANCE)->getNumComp(); + + $oneTimeWindowOpen = AboHelper::isOneTimeWindowOpen($user_abo); + if ($oneTimeWindowOpen) { + AboOrderCart::addOneTimeItemsToYard($user_abo); + AboOrderCart::checkNumOfCompProducts($user_abo); + } + $summary = AboOrderCart::getSplitSummary(); return view('portal.abo.my_abo', [ 'user_abo' => $user_abo, @@ -75,6 +86,9 @@ class AboController extends Controller 'view' => $view, 'comp_products' => [], 'isAdmin' => false, + 'one_time_window_open' => $oneTimeWindowOpen, + 'summary' => $summary, + 'base_comp_count' => $baseCompCount, ]); } @@ -98,11 +112,15 @@ class AboController extends Controller if (Request::ajax()) { $message = false; + // Yard für Gewichts-/Versandprüfung initialisieren (Versandland setzen) + AboOrderCart::initYard($user_abo); // addProduct if ($data['action'] === 'addProduct') { if ($product = Product::find($data['product_id'])) { - if ($UserAboItem = UserAboItem::where('user_abo_id', $user_abo->id)->where('product_id', $product->id)->where('comp', 0)->first()) { + if (AboOrderCart::exceedsMaxWeight($user_abo, (int) $product->weight)) { + $message = __('msg.cart_max_weight_reached'); + } elseif ($UserAboItem = UserAboItem::where('user_abo_id', $user_abo->id)->where('product_id', $product->id)->where('comp', 0)->first()) { $qtyBefore = $UserAboItem->qty; $UserAboItem->qty = $UserAboItem->qty + 1; $UserAboItem->save(); @@ -132,9 +150,14 @@ class AboController extends Controller if ($isAddOnlyMode && $qty < $UserAboItem->qty) { $qty = $UserAboItem->qty; } - $UserAboItem->qty = $qty; - $UserAboItem->save(); - AboItemHistoryService::logQtyChanged($user_abo, $UserAboItem, $qtyBefore, $qty, $view); + $additionalWeight = (int) $product->weight * ($qty - $qtyBefore); + if ($additionalWeight > 0 && AboOrderCart::exceedsMaxWeight($user_abo, $additionalWeight)) { + $message = __('msg.cart_max_weight_reached'); + } else { + $UserAboItem->qty = $qty; + $UserAboItem->save(); + AboItemHistoryService::logQtyChanged($user_abo, $UserAboItem, $qtyBefore, $qty, $view); + } } } } @@ -187,15 +210,32 @@ class AboController extends Controller AboOrderCart::initYard($user_abo); AboOrderCart::makeOrderYard($user_abo); + $baseCompCount = Yard::instance(AboOrderCart::INSTANCE)->getNumComp(); + if (AboHelper::isOneTimeWindowOpen($user_abo)) { + AboOrderCart::addOneTimeItemsToYard($user_abo->fresh()); + } AboOrderCart::checkNumOfCompProducts($user_abo); + $summary = AboOrderCart::getSplitSummary(); $error_message = $message ? $message : false; - $html_cart = view('admin.abo._order_abo_show', ['user_abo' => $user_abo, 'error_message' => $error_message, 'add_only_mode' => $isAddOnlyMode])->render(); - $html_comp = view('user.order.comp_product', $data)->render(); + $html_cart = view('admin.abo._order_abo_show', [ + 'user_abo' => $user_abo, + 'error_message' => $error_message, + 'split_mode' => AboHelper::isOneTimeWindowOpen($user_abo), + 'summary' => $summary, + 'add_only_mode' => $isAddOnlyMode, + ])->render(); + $html_comp = view('user.order.comp_product', array_merge($data, [ + 'cart_instance' => AboOrderCart::INSTANCE, + 'base_comp_count' => $baseCompCount, + ]))->render(); + $html_summary = view('admin.abo._order_combined_summary', [ + 'summary' => $summary, + ])->render(); $amount = $user_abo->getFormattedAmount(); - return response()->json(['response' => true, 'data' => $data, 'html_cart' => $html_cart, 'html_comp' => $html_comp, 'amount' => $amount]); + return response()->json(['response' => true, 'data' => $data, 'html_cart' => $html_cart, 'html_comp' => $html_comp, 'html_summary' => $html_summary, 'amount' => $amount]); } } } @@ -223,8 +263,8 @@ class AboController extends Controller return \DataTables::eloquent($query) ->addColumn('add_card', function (Product $product) { - $tax_free = Yard::instance('shopping')->getUserTaxFree(); - $price = $product->getFormattedPriceWith($tax_free, false, Yard::instance('shopping')->getUserCountry()); + $tax_free = Yard::instance(AboOrderCart::INSTANCE)->getUserTaxFree(); + $price = $product->getFormattedPriceWith($tax_free, false, Yard::instance(AboOrderCart::INSTANCE)->getUserCountry()); return ''; + }) + ->addColumn('picture', function (Product $product) { + if (count($product->images)) { + return ''; + } + + return ''; + }) + ->addColumn('name', function (Product $product) { + return ''.$product->getLang('name').''; + }) + ->addColumn('points', function (Product $product) { + return ''.$product->getFormattedPoints().''; + }) + ->addColumn('price_net', function (Product $product) { + return ''.$product->getFormattedPriceWith(true, false, Yard::instance(AboOrderCart::INSTANCE)->getUserCountry()).' €'; + }) + ->addColumn('price_gross', function (Product $product) { + return ''.$product->getFormattedPriceWith(false, false, Yard::instance(AboOrderCart::INSTANCE)->getUserCountry()).' €'; + }) + ->filterColumn('product', function ($query, $keyword) { + if ($keyword != '') { + $query->where('name', 'LIKE', '%'.$keyword.'%'); + } + }) + ->orderColumn('name', 'name $1') + ->orderColumn('number', 'number $1') + ->rawColumns(['add_card', 'points', 'name', 'picture', 'price_net', 'price_gross']) + ->make(true); + } + + /** + * @return array> + */ + private function oneTimeItemsPayload(UserAbo $user_abo): array + { + return $user_abo->one_time_items()->with('product')->get()->map(function (UserAboOneTimeItem $item) { + return [ + 'id' => $item->id, + 'product_id' => $item->product_id, + 'name' => $item->product?->getLang('name'), + 'qty' => $item->qty, + 'price' => $item->price, + 'total' => round($item->price * $item->qty, 2), + ]; + })->all(); + } + public function checkNeedBasisProduct($user_abo, $product, $order_item_id) { if (AboHelper::getAboShowOn($product) !== 'base') { diff --git a/app/Http/Controllers/Portal/OrderController.php b/app/Http/Controllers/Portal/OrderController.php index ea7e042..7c58752 100644 --- a/app/Http/Controllers/Portal/OrderController.php +++ b/app/Http/Controllers/Portal/OrderController.php @@ -238,6 +238,13 @@ class OrderController extends Controller $image = $product->images->first()?->slug ?? ''; $yard = Yard::instance($this->instance); + $additionalWeight = (int) $product->weight * $quantity; + if ($additionalWeight > 0 && $yard->exceedsMaxWeight($additionalWeight)) { + \Session::flash('alert-error', __('msg.cart_max_weight_reached')); + + return; + } + $cartItem = $yard->add( $product->id, $product->getLang('name'), diff --git a/app/Http/Controllers/User/AboController.php b/app/Http/Controllers/User/AboController.php index 2ef531b..68dfa7c 100644 --- a/app/Http/Controllers/User/AboController.php +++ b/app/Http/Controllers/User/AboController.php @@ -3,9 +3,11 @@ namespace App\Http\Controllers\User; use App\Http\Controllers\Controller; +use App\Http\Requests\Abo\AboOneTimeItemRequest; use App\Models\Product; use App\Models\UserAbo; use App\Models\UserAboItem; +use App\Models\UserAboOneTimeItem; use App\Repositories\AboRepository; use App\Services\AboHelper; use App\Services\AboItemHistoryService; @@ -85,18 +87,30 @@ class AboController extends Controller // holt die aktuellen UserAccount Daten oder die Userdaten des Abo $customer_detail = AboOrderCart::getCustomerDetail(); AboOrderCart::makeOrderYard($user_abo); + $baseCompCount = Yard::instance(AboOrderCart::INSTANCE)->getNumComp(); + + $oneTimeWindowOpen = AboHelper::isOneTimeWindowOpen($user_abo); + if ($oneTimeWindowOpen) { + AboOrderCart::addOneTimeItemsToYard($user_abo); + AboOrderCart::checkNumOfCompProducts($user_abo); + } $comp_products = []; if ($user_abo->is_for === 'me') { $comp_products = Shop::getCompProducts('abo-me'); } + $summary = AboOrderCart::getSplitSummary(); + $data = [ 'user_abo' => $user_abo, 'isAdmin' => false, 'customer_detail' => $customer_detail, 'view' => $view, 'comp_products' => $comp_products, + 'one_time_window_open' => $oneTimeWindowOpen, + 'summary' => $summary, + 'base_comp_count' => $baseCompCount, ]; return view('user.abo.detail', $data); @@ -121,10 +135,14 @@ class AboController extends Controller if (Request::ajax()) { $message = false; + // Yard für Gewichts-/Versandprüfung initialisieren (Versandland setzen) + AboOrderCart::initYard($user_abo); // addProduct if ($data['action'] === 'addProduct') { if ($product = Product::find($data['product_id'])) { - if ($UserAboItem = UserAboItem::where('user_abo_id', $user_abo->id)->where('product_id', $product->id)->where('comp', 0)->first()) { + if (AboOrderCart::exceedsMaxWeight($user_abo, (int) $product->weight)) { + $message = __('msg.cart_max_weight_reached'); + } elseif ($UserAboItem = UserAboItem::where('user_abo_id', $user_abo->id)->where('product_id', $product->id)->where('comp', 0)->first()) { $qtyBefore = $UserAboItem->qty; $UserAboItem->qty = $UserAboItem->qty + 1; $UserAboItem->save(); @@ -155,9 +173,14 @@ class AboController extends Controller if ($isAddOnlyMode && $qty < $UserAboItem->qty) { $qty = $UserAboItem->qty; } - $UserAboItem->qty = $qty; - $UserAboItem->save(); - AboItemHistoryService::logQtyChanged($user_abo, $UserAboItem, $qtyBefore, $qty, $editView); + $additionalWeight = (int) $product->weight * ($qty - $qtyBefore); + if ($additionalWeight > 0 && AboOrderCart::exceedsMaxWeight($user_abo, $additionalWeight)) { + $message = __('msg.cart_max_weight_reached'); + } else { + $UserAboItem->qty = $qty; + $UserAboItem->save(); + AboItemHistoryService::logQtyChanged($user_abo, $UserAboItem, $qtyBefore, $qty, $editView); + } } } } @@ -209,23 +232,112 @@ class AboController extends Controller AboOrderCart::initYard($user_abo); AboOrderCart::makeOrderYard($user_abo); // reCalculateShippingPrice + $baseCompCount = Yard::instance(AboOrderCart::INSTANCE)->getNumComp(); + if (AboHelper::isOneTimeWindowOpen($user_abo)) { + AboOrderCart::addOneTimeItemsToYard($user_abo->fresh()); + } AboOrderCart::checkNumOfCompProducts($user_abo); // after reCalculateShippingPrice check it and remove or add comp product if ($user_abo->is_for === 'me') { $data['comp_products'] = Shop::getCompProducts('abo-me'); } + $summary = AboOrderCart::getSplitSummary(); $error_message = $message ? $message : false; - $html_cart = view('admin.abo._order_abo_show', ['user_abo' => $user_abo, 'error_message' => $error_message, 'add_only_mode' => $isAddOnlyMode])->render(); - $html_comp = view('user.order.comp_product', $data)->render(); + $html_cart = view('admin.abo._order_abo_show', [ + 'user_abo' => $user_abo, + 'error_message' => $error_message, + 'split_mode' => AboHelper::isOneTimeWindowOpen($user_abo), + 'summary' => $summary, + 'add_only_mode' => $isAddOnlyMode, + ])->render(); + $html_comp = view('user.order.comp_product', array_merge($data, [ + 'cart_instance' => AboOrderCart::INSTANCE, + 'base_comp_count' => $baseCompCount, + ]))->render(); + $html_summary = view('admin.abo._order_combined_summary', [ + 'summary' => $summary, + ])->render(); $amount = $user_abo->getFormattedAmount(); // $html_total = view("user.homeparty.show_total_order", ['homeparty' => $homeparty])->render(); - return response()->json(['response' => true, 'data' => $data, 'html_cart' => $html_cart, 'html_comp' => $html_comp, 'amount' => $amount]); + return response()->json(['response' => true, 'data' => $data, 'html_cart' => $html_cart, 'html_comp' => $html_comp, 'html_summary' => $html_summary, 'amount' => $amount]); } } } + public function oneTime(AboOneTimeItemRequest $request, $view, $id) + { + $user_abo = UserAbo::findOrFail($id); + $this->checkPermissions($view, $user_abo); + $editView = \Auth::user()?->isAdmin() ? 'admin' : $view; + $isAddOnlyMode = AboHelper::isAddOnlyMode($user_abo, $editView); + + if (! AboHelper::isOneTimeWindowOpen($user_abo)) { + return response()->json([ + 'response' => false, + 'message' => __('abo.onetime_window_closed'), + ], 403); + } + + AboOrderCart::initYard($user_abo); + $message = (new \App\Services\AboOneTimeService)->handleAction($user_abo, $request->validated()); + + AboOrderCart::initYard($user_abo); + AboOrderCart::makeOrderYard($user_abo); + $baseCompCount = Yard::instance(AboOrderCart::INSTANCE)->getNumComp(); + AboOrderCart::addOneTimeItemsToYard($user_abo->fresh()); + AboOrderCart::checkNumOfCompProducts($user_abo); + + $user_abo = $user_abo->fresh(); + $summary = AboOrderCart::getSplitSummary(); + $compProducts = $user_abo->is_for === 'me' ? Shop::getCompProducts('abo-me') : []; + + return response()->json([ + 'response' => ! $message, + 'message' => $message ?: null, + 'summary' => $summary, + 'one_time_items' => $this->oneTimeItemsPayload($user_abo), + 'html_onetime' => view('admin.abo._order_onetime_show', [ + 'user_abo' => $user_abo, + 'summary' => $summary, + 'error_message' => $message ?: false, + ])->render(), + 'html_abo' => view('admin.abo._order_abo_show', [ + 'user_abo' => $user_abo, + 'split_mode' => true, + 'summary' => $summary, + 'only_show_products' => false, + 'add_only_mode' => $isAddOnlyMode, + ])->render(), + 'html_summary' => view('admin.abo._order_combined_summary', [ + 'summary' => $summary, + ])->render(), + 'html_comp' => view('user.order.comp_product', [ + 'comp_products' => $compProducts, + 'cart_instance' => AboOrderCart::INSTANCE, + 'base_comp_count' => $baseCompCount, + ])->render(), + ]); + } + + /** + * @return array> + */ + private function oneTimeItemsPayload(UserAbo $user_abo): array + { + return $user_abo->one_time_items()->with('product')->get()->map(function (UserAboOneTimeItem $item) { + return [ + 'id' => $item->id, + 'product_id' => $item->product_id, + 'name' => $item->product?->getLang('name'), + 'qty' => $item->qty, + 'price' => $item->price, + 'total' => round($item->price * $item->qty, 2), + ]; + })->all(); + } + public function check_need_basis_product($user_abo, $product, $order_item_id) { // Wenn das zu entfernende Produkt kein Basis-Produkt ist, keine weitere Prüfung nötig @@ -278,9 +390,9 @@ class AboController extends Controller ->addColumn('add_card', function (Product $product) use ($user_abo) { $ufactor = $user_abo->is_for === 'me' ? true : false; - $tax_free = $user_abo->is_for === 'me' ? true : Yard::instance('shopping')->getUserTaxFree(); + $tax_free = $user_abo->is_for === 'me' ? true : Yard::instance(AboOrderCart::INSTANCE)->getUserTaxFree(); - $price = $product->getFormattedPriceWith($tax_free, $ufactor, Yard::instance('shopping')->getUserCountry()); + $price = $product->getFormattedPriceWith($tax_free, $ufactor, Yard::instance(AboOrderCart::INSTANCE)->getUserCountry()); return ''; + }) + ->addColumn('picture', function (Product $product) { + if (count($product->images)) { + return ''; + } + + return ''; + }) + ->addColumn('name', function (Product $product) { + return ''.$product->getLang('name').''; + }) + ->addColumn('points', function (Product $product) { + return ''.$product->getFormattedPoints().''; + }) + ->addColumn('price_net', function (Product $product) use ($user_abo) { + $ufactor = $user_abo->is_for === 'me' ? true : false; + + return ''.$product->getFormattedPriceWith(true, $ufactor, Yard::instance(AboOrderCart::INSTANCE)->getUserCountry()).' €'; + }) + ->addColumn('price_gross', function (Product $product) use ($user_abo) { + $ufactor = $user_abo->is_for === 'me' ? true : false; + + return ''.$product->getFormattedPriceWith(false, $ufactor, Yard::instance(AboOrderCart::INSTANCE)->getUserCountry()).' €'; + }) + ->filterColumn('product', function ($query, $keyword) { + if ($keyword != '') { + $query->where('name', 'LIKE', '%'.$keyword.'%'); + } + }) + ->orderColumn('name', 'name $1') + ->orderColumn('number', 'number $1') + ->rawColumns(['add_card', 'points', 'name', 'picture', 'price_net', 'price_gross']) + ->make(true); + } + private function checkPermissions($view, $user_abo) { \Log::info('checkPermissions', ['view' => $view, 'user_abo' => $user_abo]); diff --git a/app/Http/Controllers/User/OrderController.php b/app/Http/Controllers/User/OrderController.php index 6143167..aed86b0 100644 --- a/app/Http/Controllers/User/OrderController.php +++ b/app/Http/Controllers/User/OrderController.php @@ -773,10 +773,11 @@ class OrderController extends Controller $isAbo = str_contains($is_for, 'abo'); $qty = isset($data['qty']) ? (int) $data['qty'] : 0; if ($qty > 0 && ! ProductOrderContext::isProductAllowedInContext($product, $isAbo, $is_for)) { - return response()->json([ - 'response' => false, - 'message' => __('msg.cart_product_not_allowed_for_order_type'), - ]); + return $this->cartUpdateRejected($data, __('msg.cart_product_not_allowed_for_order_type')); + } + + if ($qty > 0 && $this->exceedsCartMaxWeight(Yard::instance('shopping'), $product, $qty)) { + return $this->cartUpdateRejected($data, __('msg.cart_max_weight_reached')); } $image = ''; @@ -831,6 +832,67 @@ class OrderController extends Controller return response()->json(['response' => true, 'data' => $data, 'html_card' => $html_card, 'html_comp' => $html_comp]); } + /** + * Lehnt ein Warenkorb-Update ab, ohne den Warenkorb zu verändern. Der Warenkorb + * wird unverändert neu gerendert (Zähler bleibt stehen) und der Hinweis als + * Inline-Meldung über dem Warenkorb ausgegeben. + * + * @param array $data + */ + private function cartUpdateRejected(array $data, string $message): \Illuminate\Http\JsonResponse + { + $data['error_message'] = $message; + + if (isset($data['product_id'])) { + $currentQty = 0; + foreach (Yard::instance('shopping')->content() as $row) { + if (! $row->options->comp && (int) $row->id === (int) $data['product_id']) { + $currentQty = (int) $row->qty; + break; + } + } + $data['qty'] = $currentQty; + } + + $html_card = view('user.order.yard_view_form', $data)->render(); + $html_comp = view('user.order.comp_product', $data)->render(); + + return response()->json([ + 'response' => true, + 'data' => $data, + 'html_card' => $html_card, + 'html_comp' => $html_comp, + 'message' => $message, + ]); + } + + /** + * Prüft, ob das Hochsetzen eines Produkts auf die Zielmenge das Maximalgewicht + * des Versandlandes überschreiten würde. Mengenreduktionen werden nie blockiert. + */ + private function exceedsCartMaxWeight(\App\Services\Yard $yard, Product $product, int $targetQty): bool + { + $productWeight = (int) $product->weight; + if ($productWeight <= 0) { + return false; + } + + $currentQty = 0; + foreach ($yard->content() as $row) { + if (! $row->options->comp && (int) $row->id === (int) $product->id) { + $currentQty = (int) $row->qty; + break; + } + } + + $additionalWeight = $productWeight * ($targetQty - $currentQty); + if ($additionalWeight <= 0) { + return false; + } + + return $yard->exceedsMaxWeight($additionalWeight); + } + /** * Handle updating shipping country */ diff --git a/app/Http/Controllers/Web/CardController.php b/app/Http/Controllers/Web/CardController.php index 6ff5002..05f12a0 100644 --- a/app/Http/Controllers/Web/CardController.php +++ b/app/Http/Controllers/Web/CardController.php @@ -28,6 +28,12 @@ class CardController extends Controller { $product = Product::find($id); if ($product && ProductOrderContext::isProductAllowedInCustomerWebshop($product)) { + $additionalWeight = (int) $product->weight * (int) $quantity; + if ($additionalWeight > 0 && Yard::instance($this->instance)->exceedsMaxWeight($additionalWeight)) { + \Session::flash('alert-error', __('msg.cart_max_weight_reached')); + + return back(); + } $image = ''; if ($product->images->count()) { $image = $product->images->first()->slug; @@ -61,11 +67,17 @@ class CardController extends Controller $product = Product::find($id); if ($product && ProductOrderContext::isProductAllowedInCustomerWebshop($product)) { + $quantity = Request::get('quantity') ? Request::get('quantity') : 1; + $additionalWeight = (int) $product->weight * (int) $quantity; + if ($additionalWeight > 0 && Yard::instance($this->instance)->exceedsMaxWeight($additionalWeight)) { + \Session::flash('alert-error', __('msg.cart_max_weight_reached')); + + return back(); + } $image = ''; if ($product->images->count()) { $image = $product->images->first()->slug; } - $quantity = Request::get('quantity') ? Request::get('quantity') : 1; $cartItem = Yard::instance($this->instance) ->add( $product->id, @@ -122,6 +134,14 @@ class CardController extends Controller if ($product && $product->is_membership_only) { $qty = 1; } + if ($product) { + $additionalWeight = (int) $product->weight * ((int) $qty - (int) $cartItem->qty); + if ($additionalWeight > 0 && Yard::instance($this->instance)->exceedsMaxWeight($additionalWeight)) { + \Session::flash('alert-error', __('msg.cart_max_weight_reached')); + + continue; + } + } } Yard::instance($this->instance)->update($rowId, $qty); Yard::instance($this->instance)->reCalculateShippingPrice(); diff --git a/app/Http/Requests/Abo/AboOneTimeItemRequest.php b/app/Http/Requests/Abo/AboOneTimeItemRequest.php new file mode 100644 index 0000000..4857e89 --- /dev/null +++ b/app/Http/Requests/Abo/AboOneTimeItemRequest.php @@ -0,0 +1,42 @@ +> + */ + public function rules(): array + { + return [ + 'action' => ['required', 'in:add,update,remove,confirm,discard'], + 'product_id' => ['required_if:action,add', 'integer', 'exists:products,id'], + 'one_time_item_id' => ['required_if:action,update,remove', 'integer', 'exists:user_abo_one_time_items,id'], + 'qty' => ['required_if:action,update', 'integer', 'min:1', 'max:100'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'action.required' => __('abo.onetime_action_required'), + 'action.in' => __('abo.onetime_action_invalid'), + 'product_id.required_if' => __('abo.product_not_found'), + 'product_id.exists' => __('abo.product_not_found'), + 'one_time_item_id.required_if' => __('abo.abo_item_not_found'), + 'one_time_item_id.exists' => __('abo.abo_item_not_found'), + 'qty.required_if' => __('abo.onetime_qty_required'), + ]; + } +} diff --git a/app/Models/ShoppingOrderItem.php b/app/Models/ShoppingOrderItem.php index a214c94..7ff156c 100644 --- a/app/Models/ShoppingOrderItem.php +++ b/app/Models/ShoppingOrderItem.php @@ -19,6 +19,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; * @property \Illuminate\Support\Carbon|null $updated_at * @property-read \App\Models\Product $product * @property-read \App\Models\ShoppingOrder $shopping_order + * * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingOrderItem newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingOrderItem newQuery() * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingOrderItem query() @@ -31,32 +32,43 @@ use Illuminate\Database\Eloquent\SoftDeletes; * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingOrderItem whereShoppingOrderId($value) * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingOrderItem whereSlug($value) * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingOrderItem whereUpdatedAt($value) + * * @property float|null $tax_rate * @property \Illuminate\Support\Carbon|null $deleted_at * @property string|null $user_deleted_at + * * @method static \Illuminate\Database\Query\Builder|\App\Models\ShoppingOrderItem onlyTrashed() * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingOrderItem whereDeletedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingOrderItem whereTaxRate($value) * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingOrderItem whereUserDeletedAt($value) * @method static \Illuminate\Database\Query\Builder|\App\Models\ShoppingOrderItem withTrashed() * @method static \Illuminate\Database\Query\Builder|\App\Models\ShoppingOrderItem withoutTrashed() + * * @property int|null $comp * @property float|null $price_net + * * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingOrderItem whereComp($value) * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingOrderItem wherePriceNet($value) + * * @property int|null $homeparty_id * @property-read \App\Models\Homeparty|null $homeparty + * * @method static \Illuminate\Database\Eloquent\Builder|ShoppingOrderItem whereHomepartyId($value) + * * @property string|null $tax * @property string|null $price_vk_net * @property string|null $discount * @property int|null $points + * * @method static \Illuminate\Database\Eloquent\Builder|ShoppingOrderItem whereDiscount($value) * @method static \Illuminate\Database\Eloquent\Builder|ShoppingOrderItem wherePoints($value) * @method static \Illuminate\Database\Eloquent\Builder|ShoppingOrderItem wherePriceVkNet($value) * @method static \Illuminate\Database\Eloquent\Builder|ShoppingOrderItem whereTax($value) + * * @property int|null $shopping_collect_order_id + * * @method static \Illuminate\Database\Eloquent\Builder|ShoppingOrderItem whereShoppingCollectOrderId($value) + * * @mixin \Eloquent */ class ShoppingOrderItem extends Model @@ -64,6 +76,7 @@ class ShoppingOrderItem extends Model protected $table = 'shopping_order_items'; use SoftDeletes; + protected $dates = ['deleted_at']; protected $fillable = [ @@ -73,6 +86,7 @@ class ShoppingOrderItem extends Model 'homeparty_id', 'shopping_collect_order_id', 'comp', + 'is_abo_addon', 'qty', 'price', 'price_net', @@ -83,6 +97,7 @@ class ShoppingOrderItem extends Model 'points', 'slug', ]; + protected $casts = [ 'qty' => 'int', 'price' => 'float', @@ -92,21 +107,38 @@ class ShoppingOrderItem extends Model 'price_vk_net' => 'float', 'discount' => 'float', 'points' => 'float', + 'is_abo_addon' => 'bool', ]; public function shopping_order() { - return $this->belongsTo('App\Models\ShoppingOrder','shopping_order_id'); + return $this->belongsTo('App\Models\ShoppingOrder', 'shopping_order_id'); + } + + /** + * Reine Abo-Positionen (keine einmalig hinzugefügten Artikel). + */ + public function scopeAboItems($query) + { + return $query->where('is_abo_addon', false); + } + + /** + * Einmalig zum Abo-Versand hinzugefügte Artikel aus dem normalen Bestellsortiment. + */ + public function scopeAddonItems($query) + { + return $query->where('is_abo_addon', true); } public function product() { - return $this->belongsTo('App\Models\Product','product_id'); + return $this->belongsTo('App\Models\Product', 'product_id'); } public function homeparty() { - return $this->belongsTo('App\Models\Homeparty','homeparty_id'); + return $this->belongsTo('App\Models\Homeparty', 'homeparty_id'); } public function getFormattedPrice() @@ -139,7 +171,6 @@ class ShoppingOrderItem extends Model return formatNumber($this->attributes['tax']); } - public function getFormattedDiscount() { return cleanNumberFormat($this->attributes['discount']); @@ -147,7 +178,7 @@ class ShoppingOrderItem extends Model public function getFormattedTotalPriceNet() { - return formatNumber($this->attributes['price_net'] * $this->attributes['qty']); + return formatNumber($this->attributes['price_net'] * $this->attributes['qty']); } public function setPointsAttribute($value) @@ -157,6 +188,6 @@ class ShoppingOrderItem extends Model public function getFormattedPoints() { - return isset($this->attributes['points']) ? formatNumber($this->attributes['points']) : ""; + return isset($this->attributes['points']) ? formatNumber($this->attributes['points']) : ''; } -} \ No newline at end of file +} diff --git a/app/Models/UserAbo.php b/app/Models/UserAbo.php index 8f70b48..7e82e7d 100644 --- a/app/Models/UserAbo.php +++ b/app/Models/UserAbo.php @@ -45,6 +45,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; * @property-read User|null $member * @property-read \App\Models\ShoppingUser $shopping_user * @property-read Collection $user_abo_items + * @property-read Collection $one_time_items * @property-read int|null $user_abo_items_count * @property-read int|null $user_abo_orders_count * @@ -173,6 +174,11 @@ class UserAbo extends Model return $this->hasMany(UserAboItem::class); } + public function one_time_items() + { + return $this->hasMany(UserAboOneTimeItem::class); + } + public function user_abo_item_histories() { return $this->hasMany(UserAboItemHistory::class); diff --git a/app/Models/UserAboItem.php b/app/Models/UserAboItem.php index fa539cb..994ca6e 100644 --- a/app/Models/UserAboItem.php +++ b/app/Models/UserAboItem.php @@ -24,7 +24,7 @@ use Yard; * @property Carbon|null $updated_at * @property Product $product * @property UserAbo $user_abo - * @package App\Models + * * @method static \Illuminate\Database\Eloquent\Builder|UserAboItem newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|UserAboItem newQuery() * @method static \Illuminate\Database\Eloquent\Builder|UserAboItem query() @@ -36,55 +36,57 @@ use Yard; * @method static \Illuminate\Database\Eloquent\Builder|UserAboItem whereStatus($value) * @method static \Illuminate\Database\Eloquent\Builder|UserAboItem whereUpdatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|UserAboItem whereUserAboId($value) + * * @mixin \Eloquent */ class UserAboItem extends Model { - protected $table = 'user_abo_items'; + protected $table = 'user_abo_items'; - protected $casts = [ - 'user_abo_id' => 'int', - 'product_id' => 'int', - 'comp' => 'int', - 'qty' => 'int', - 'status' => 'int' - ]; + protected $casts = [ + 'user_abo_id' => 'int', + 'product_id' => 'int', + 'comp' => 'int', + 'qty' => 'int', + 'status' => 'int', + ]; - protected $fillable = [ - 'user_abo_id', - 'product_id', - 'comp', - 'qty', - 'status' - ]; + protected $fillable = [ + 'user_abo_id', + 'product_id', + 'comp', + 'qty', + 'status', + ]; - public function product() - { - return $this->belongsTo(Product::class); - } + public function product() + { + return $this->belongsTo(Product::class); + } - public function user_abo() - { - return $this->belongsTo(UserAbo::class); - } + public function user_abo() + { + return $this->belongsTo(UserAbo::class); + } - public function getPrice() - { - $ufactor = $this->user_abo->is_for === 'me' ? true : false; - $tax_free = $ufactor ? true : Yard::instance('shopping')->getUserTaxFree(); - $userCountry = Yard::instance('shopping')->getUserCountry(); - return $this->product->getPriceWith($tax_free, $ufactor, $userCountry); - } + public function getPrice() + { + $ufactor = $this->user_abo->is_for === 'me' ? true : false; + $tax_free = $ufactor ? true : Yard::instance(\App\Services\AboOrderCart::INSTANCE)->getUserTaxFree(); + $userCountry = Yard::instance(\App\Services\AboOrderCart::INSTANCE)->getUserCountry(); + return $this->product->getPriceWith($tax_free, $ufactor, $userCountry); + } - public function getFormattedPrice(){ - /** der Preis wird für den User berechnet */ - return Util::formatNumber($this->getPrice()); - } + public function getFormattedPrice() + { + /** der Preis wird für den User berechnet */ + return Util::formatNumber($this->getPrice()); + } - - public function getFormattedTotalPrice(){ - /** der Preis wird für den User berechnet */ - return Util::formatNumber($this->getPrice() * $this->qty); - } + public function getFormattedTotalPrice() + { + /** der Preis wird für den User berechnet */ + return Util::formatNumber($this->getPrice() * $this->qty); + } } diff --git a/app/Models/UserAboOneTimeItem.php b/app/Models/UserAboOneTimeItem.php new file mode 100644 index 0000000..9bb9ecb --- /dev/null +++ b/app/Models/UserAboOneTimeItem.php @@ -0,0 +1,101 @@ + 'int', + 'product_id' => 'int', + 'comp' => 'int', + 'qty' => 'int', + 'confirmed_qty' => 'int', + 'confirmed_at' => 'datetime', + 'price' => 'float', + 'price_net' => 'float', + 'tax_rate' => 'float', + 'tax' => 'float', + 'price_vk_net' => 'float', + 'discount' => 'float', + 'points' => 'int', + 'status' => 'int', + ]; + + protected $fillable = [ + 'user_abo_id', + 'product_id', + 'comp', + 'qty', + 'confirmed_qty', + 'confirmed_at', + 'price', + 'price_net', + 'tax_rate', + 'tax', + 'price_vk_net', + 'discount', + 'points', + 'status', + ]; + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function user_abo(): BelongsTo + { + return $this->belongsTo(UserAbo::class); + } + + public function isConfirmed(): bool + { + return $this->confirmed_at !== null && $this->confirmed_qty === $this->qty; + } + + public function getFormattedPrice(): string + { + return Util::formatNumber($this->price); + } + + public function getFormattedTotalPrice(): string + { + return Util::formatNumber($this->price * $this->qty); + } +} diff --git a/app/Services/AboHelper.php b/app/Services/AboHelper.php index 615893d..f2953b2 100644 --- a/app/Services/AboHelper.php +++ b/app/Services/AboHelper.php @@ -21,6 +21,12 @@ class AboHelper */ public const MIN_DAYS_UNTIL_FIRST_ABO_EXECUTION = 10; + /** + * Standard-Zeitfenster (Kalendertage) vor der Ausführung, in dem einmalige + * Produkte aus dem normalen Sortiment hinzugefügt werden dürfen. + */ + public const DEFAULT_ONETIME_WINDOW_DAYS = 4; + public static $txaction_filter_text = [ 'paid' => 'paymend_paid', 'appointed' => 'paymend_open', @@ -168,6 +174,41 @@ class AboHelper return $nextDate->gt($date) ? $nextDate : $nextDate->addMonth(1); } + /** + * Konfiguriertes Zeitfenster (Kalendertage) für einmalige Produkte vor der Ausführung. + */ + public static function getOneTimeWindowDays(): int + { + $days = (int) \App\Models\Setting::getContentBySlug('abo-onetime-window-days'); + + return $days > 0 ? $days : self::DEFAULT_ONETIME_WINDOW_DAYS; + } + + /** + * Prüft, ob für dieses Abo aktuell einmalig Produkte aus dem normalen + * Bestellsortiment hinzugefügt werden dürfen (Zeitfenster vor der Ausführung). + * + * Nur die zeitliche Bedingung wird geprüft. Aufrufer sollten zusätzlich den + * Abo-Zustand (active/status) berücksichtigen, falls relevant. + */ + public static function isOneTimeWindowOpen(UserAbo $userAbo): bool + { + if (! $userAbo->next_date) { + return false; + } + + $today = Carbon::today(); + $nextDate = Carbon::parse($userAbo->next_date)->startOfDay(); + + if ($nextDate->lt($today)) { + return false; + } + + $daysUntilExecution = $today->diffInDays($nextDate); + + return $daysUntilExecution <= self::getOneTimeWindowDays(); + } + public static function getFirstAboDate($date, $abo_interval) { $reference = Carbon::parse($date)->startOfDay(); diff --git a/app/Services/AboOneTimeService.php b/app/Services/AboOneTimeService.php new file mode 100644 index 0000000..57c97db --- /dev/null +++ b/app/Services/AboOneTimeService.php @@ -0,0 +1,181 @@ + $data + */ + public function handleAction(UserAbo $userAbo, array $data): ?string + { + $action = $data['action'] ?? null; + + return match ($action) { + 'add' => $this->add($userAbo, (int) ($data['product_id'] ?? 0)), + 'update' => $this->update($userAbo, (int) ($data['one_time_item_id'] ?? 0), (int) ($data['qty'] ?? 1)), + 'remove' => $this->remove($userAbo, (int) ($data['one_time_item_id'] ?? 0)), + 'confirm' => $this->confirm($userAbo), + 'discard' => $this->discard($userAbo), + default => __('abo.onetime_action_invalid'), + }; + } + + private function add(UserAbo $userAbo, int $productId): ?string + { + $product = Product::find($productId); + $allowedIds = ProductOrderContext::allowedShowOnIds(false, $userAbo->is_for === 'me' ? 'me' : 'ot-customer'); + + if (! $product || ! $product->active || ! ProductOrderContext::productMatchesShowOn($product, $allowedIds)) { + return __('abo.onetime_product_not_allowed'); + } + + if (AboOrderCart::exceedsMaxWeight($userAbo, (int) $product->weight)) { + return __('msg.cart_max_weight_reached'); + } + + $existing = UserAboOneTimeItem::withTrashed() + ->where('user_abo_id', $userAbo->id) + ->where('product_id', $product->id) + ->first(); + + if ($existing) { + if ($existing->trashed()) { + $existing->restore(); + $existing->qty = $existing->confirmed_at ? max(1, (int) $existing->confirmed_qty) + 1 : 1; + } else { + $existing->qty = min(100, $existing->qty + 1); + } + $existing->qty = min(100, $existing->qty); + $existing->status = 0; + $existing->save(); + + return null; + } + + $snapshot = AboOrderCart::buildOneTimeSnapshot($product, $userAbo); + UserAboOneTimeItem::create(array_merge($snapshot, [ + 'user_abo_id' => $userAbo->id, + 'product_id' => $product->id, + 'comp' => 0, + 'qty' => 1, + 'status' => 0, + ])); + + return null; + } + + private function update(UserAbo $userAbo, int $itemId, int $qty): ?string + { + $item = UserAboOneTimeItem::where('user_abo_id', $userAbo->id)->find($itemId); + if (! $item) { + return __('abo.abo_item_not_found'); + } + + $targetQty = max(1, min(100, $qty)); + $product = Product::find($item->product_id); + if ($product) { + $additionalWeight = (int) $product->weight * ($targetQty - (int) $item->qty); + if ($additionalWeight > 0 && AboOrderCart::exceedsMaxWeight($userAbo, $additionalWeight)) { + return __('msg.cart_max_weight_reached'); + } + } + + $item->qty = $targetQty; + $item->status = $item->isConfirmed() ? 1 : 0; + $item->save(); + + return null; + } + + private function remove(UserAbo $userAbo, int $itemId): ?string + { + $item = UserAboOneTimeItem::where('user_abo_id', $userAbo->id)->find($itemId); + if (! $item) { + return __('abo.abo_item_not_found'); + } + + if ($item->confirmed_at) { + $item->delete(); + } else { + $item->forceDelete(); + } + + return null; + } + + private function confirm(UserAbo $userAbo): ?string + { + UserAboOneTimeItem::withTrashed() + ->where('user_abo_id', $userAbo->id) + ->get() + ->each(function (UserAboOneTimeItem $item): void { + if ($item->trashed()) { + $item->forceDelete(); + + return; + } + + $item->confirmed_qty = $item->qty; + $item->confirmed_at = now(); + $item->status = 1; + $item->save(); + }); + + return null; + } + + private function discard(UserAbo $userAbo): ?string + { + UserAboOneTimeItem::withTrashed() + ->where('user_abo_id', $userAbo->id) + ->get() + ->each(function (UserAboOneTimeItem $item): void { + if (! $item->confirmed_at) { + $item->forceDelete(); + + return; + } + + if ($item->trashed()) { + $item->restore(); + } + + $item->qty = max(1, (int) $item->confirmed_qty); + $item->status = 1; + $item->save(); + }); + + return null; + } + + public static function hasUnconfirmedChanges(UserAbo $userAbo): bool + { + return UserAboOneTimeItem::withTrashed() + ->where('user_abo_id', $userAbo->id) + ->get() + ->contains(function (UserAboOneTimeItem $item): bool { + if ($item->trashed()) { + return $item->confirmed_at !== null; + } + + return ! $item->isConfirmed(); + }); + } + + public static function hasConfirmedItems(UserAbo $userAbo): bool + { + return UserAboOneTimeItem::where('user_abo_id', $userAbo->id) + ->whereNotNull('confirmed_at') + ->exists(); + } +} diff --git a/app/Services/AboOrderCart.php b/app/Services/AboOrderCart.php index a64fb4e..e78a9ea 100644 --- a/app/Services/AboOrderCart.php +++ b/app/Services/AboOrderCart.php @@ -5,18 +5,66 @@ namespace App\Services; use App\Models\Product; use App\Models\ShippingCountry; use App\Models\ShoppingUser; +use App\Models\UserAbo; use App\Models\UserAboItem; use App\User; use Yard; class AboOrderCart { + /** + * Eigene Warenkorb-Instanz für den Abo-Detail-/Bearbeitungs-Flow. + * Bewusst getrennt von der regulären Bestell-Instanz ('shopping') und der + * Abo-Anlage-Instanz ('subscription'), damit sich die Warenkörbe über + * mehrere Browser-Fenster/Tabs hinweg nicht vermischen. + */ + public const INSTANCE = 'abo'; + private static $user_abo; private static $is_for; private static $customer_detail; + /** + * Gesamtgewicht (in Gramm) aller regulären Abo-Artikel und Einmal-Artikel des Abos. + * Comp-Produkte zählen nicht (Gewicht 0). + */ + public static function combinedWeight(UserAbo $user_abo): int + { + $weight = 0; + foreach ($user_abo->user_abo_items as $item) { + if ($item->comp) { + continue; + } + if ($item->product) { + $weight += (int) $item->product->weight * (int) $item->qty; + } + } + foreach ($user_abo->one_time_items()->get() as $item) { + $product = Product::find($item->product_id); + if ($product) { + $weight += (int) $product->weight * (int) $item->qty; + } + } + + return $weight; + } + + /** + * Prüft, ob das kombinierte Abo-Gewicht zzgl. eines Zusatzgewichts das Maximalgewicht + * des Versandlandes überschreiten würde. Setzt einen über initYard() initialisierten Yard voraus. + */ + public static function exceedsMaxWeight(UserAbo $user_abo, int $additionalWeight = 0): bool + { + $maxWeight = Yard::instance(self::INSTANCE)->getMaxWeight(); + if ($maxWeight <= 0) { + return false; + } + + return (self::combinedWeight($user_abo) + $additionalWeight) > $maxWeight; + } + public static function initYard($user_abo) { @@ -26,8 +74,11 @@ class AboOrderCart self::$is_for = null; self::$customer_detail = null; + // Sicherstellen, dass UserService den Abo-Yard initialisiert (nicht 'shopping') + UserService::setInstance(self::INSTANCE); + // Yard komplett leeren - wichtig für Batch-Verarbeitung mehrerer Abos - $yard = Yard::instance('shopping'); + $yard = Yard::instance(self::INSTANCE); $itemsBeforeDestroy = $yard->content()->count(); $yard->destroy(); @@ -81,7 +132,7 @@ class AboOrderCart // WICHTIG: Yard IMMER leeren, um sicherzustellen, dass keine Produkte aus vorherigen Aufrufen vorhanden sind // Dies ist besonders wichtig bei wiederholten Aufrufen der detail-Funktion (z.B. durch AJAX-Requests) - $yard = Yard::instance('shopping'); + $yard = Yard::instance(self::INSTANCE); $itemsBefore = $yard->content()->count(); $yard->destroy(); @@ -113,9 +164,9 @@ class AboOrderCart foreach ($abo_items as $abo_item) { self::addProductToCart($abo_item); } - Yard::instance('shopping')->reCalculateShippingPrice(); + Yard::instance(self::INSTANCE)->reCalculateShippingPrice(); - $user_abo->amount = Yard::instance('shopping')->totalWithShipping(2, '.', '') * 100; + $user_abo->amount = Yard::instance(self::INSTANCE)->totalWithShipping(2, '.', '') * 100; $user_abo->save(); } @@ -123,12 +174,12 @@ class AboOrderCart { $product = Product::find($item->product_id); - $tax_free = Yard::instance('shopping')->getUserTaxFree(); - $user_country = Yard::instance('shopping')->getUserCountry(); + $tax_free = Yard::instance(self::INSTANCE)->getUserTaxFree(); + $user_country = Yard::instance(self::INSTANCE)->getUserCountry(); if ($product) { if ($item->comp) { - $cartItem = Yard::instance('shopping')->add( + $cartItem = Yard::instance(self::INSTANCE)->add( $product->id, $product->getLang('name'), 1, @@ -149,7 +200,7 @@ class AboOrderCart return true; } if (self::$is_for === 'ot-customer' || self::$is_for === 'abo-ot-customer') { - $cartItem = Yard::instance('shopping') + $cartItem = Yard::instance(self::INSTANCE) ->add( $product->id, $product->getLang('name'), @@ -160,7 +211,7 @@ class AboOrderCart ['image' => '', 'slug' => $product->slug, 'weight' => $product->weight, 'points' => $product->points, 'no_commission' => $product->no_commission, 'no_free_shipping' => $product->no_free_shipping, 'show_on' => $product->show_on] ); } else { - $cartItem = Yard::instance('shopping') + $cartItem = Yard::instance(self::INSTANCE) ->add( $product->id, $product->getLang('name'), @@ -179,44 +230,195 @@ class AboOrderCart } } + /** + * Lädt die einmalig hinzugefügten Produkte (normales Bestellsortiment) zusätzlich + * zu den Abo-Produkten in denselben Yard – mit dem eingefrorenen Snapshot-Preis und + * der Cart-Option `abo_addon = true`. Anschließend wird der Versand über das + * Gesamtgewicht neu berechnet. + * + * Wichtig: Diese Methode verändert NICHT `user_abos.amount` (reiner Abo-Betrag). + * Sie muss nach {@see self::makeOrderYard()} aufgerufen werden. + */ + public static function addOneTimeItemsToYard(UserAbo $user_abo): void + { + self::$user_abo = $user_abo; + self::$is_for = $user_abo->is_for === 'me' ? 'abo-me' : 'abo-ot-customer'; + + $yard = Yard::instance(self::INSTANCE); + + foreach ($user_abo->one_time_items()->get() as $item) { + $product = Product::find($item->product_id); + if (! $product) { + continue; + } + + $cartItem = $yard->add( + $product->id, + $product->getLang('name'), + $item->qty, + (float) $item->price, + false, + false, + [ + 'image' => '', + 'slug' => $product->slug, + 'weight' => $product->weight, + 'points' => $item->points, + 'no_commission' => $product->no_commission, + 'no_free_shipping' => $product->no_free_shipping, + 'show_on' => $product->show_on, + 'comp' => 0, + 'abo_addon' => true, + 'one_time_item_id' => $item->id, + 'product_id' => $product->id, + ] + ); + Yard::setTax($cartItem->rowId, (float) $item->tax_rate); + } + + $yard->reCalculateShippingPrice(); + } + + /** + * Erstellt einen Preis-Snapshot für ein einmalig hinzugefügtes Produkt, konsistent + * zur Cart-Preisberechnung in {@see self::addProductToCart()}. + * + * @return array{price: float, price_net: float, tax_rate: float, tax: float, price_vk_net: float, discount: float, points: int} + */ + public static function buildOneTimeSnapshot(Product $product, UserAbo $user_abo): array + { + $yard = Yard::instance(self::INSTANCE); + $tax_free = $yard->getUserTaxFree(); + $country = $yard->getUserCountry(); + $is_me = $user_abo->is_for === 'me'; + + if ($is_me) { + $price = (float) $product->getPriceWith($tax_free, true, $country, false, $user_abo->user); + } else { + $price = round((float) $product->getPriceWith($tax_free, false, $country, false, $user_abo->user), 1); + } + + $tax_rate = $tax_free ? 0.0 : (float) $product->getTaxWith($country); + $price_net = $tax_rate > 0 ? round($price / ((100 + $tax_rate) / 100), 3) : round($price, 3); + $tax = round($price - $price_net, 3); + $price_vk_net = round((float) $product->getPriceWith(true, false, $country), 3); + + $discount = 0.0; + if ($is_me && ! $product->no_commission && $user_abo->user && $user_abo->user->user_level) { + $discount = (float) $user_abo->user->user_level->getFormattedMargin(); + } + + return [ + 'price' => round($price, 2), + 'price_net' => $price_net, + 'tax_rate' => $tax_rate, + 'tax' => $tax, + 'price_vk_net' => $price_vk_net, + 'discount' => $discount, + 'points' => (int) $product->points, + ]; + } + + /** + * Liefert getrennte Zwischensummen für Abo-Produkte und einmalig hinzugefügte + * Artikel sowie die kombinierten Gesamtwerte (Versand nach Gesamtgewicht). + * + * @return array{ + * abo: array{net: float, gross: float, tax: float}, + * one_time: array{net: float, gross: float, tax: float}, + * shipping_net: float, + * shipping_gross: float, + * total_with_shipping: float, + * has_one_time: bool + * } + */ + public static function getSplitSummary(): array + { + $yard = Yard::instance(self::INSTANCE); + + $abo = ['net' => 0.0, 'gross' => 0.0, 'tax' => 0.0]; + $oneTime = ['net' => 0.0, 'gross' => 0.0, 'tax' => 0.0]; + + foreach ($yard->content() as $row) { + $lineGross = (float) $row->price * $row->qty; + $lineNet = $row->taxRate > 0 ? $lineGross / ((100 + $row->taxRate) / 100) : $lineGross; + $lineTax = $lineGross - $lineNet; + + $bucket = ($row->options->abo_addon ?? false) ? 'oneTime' : 'abo'; + if ($bucket === 'oneTime') { + $oneTime['net'] += $lineNet; + $oneTime['gross'] += $lineGross; + $oneTime['tax'] += $lineTax; + } else { + $abo['net'] += $lineNet; + $abo['gross'] += $lineGross; + $abo['tax'] += $lineTax; + } + } + + return [ + 'abo' => [ + 'net' => round($abo['net'], 2), + 'gross' => round($abo['gross'], 2), + 'tax' => round($abo['tax'], 2), + ], + 'one_time' => [ + 'net' => round($oneTime['net'], 2), + 'gross' => round($oneTime['gross'], 2), + 'tax' => round($oneTime['tax'], 2), + ], + 'shipping_net' => (float) $yard->shippingNet(2, '.', ''), + 'shipping_gross' => (float) $yard->shipping(2, '.', ''), + 'total_with_shipping' => (float) $yard->totalWithShipping(2, '.', ''), + 'has_one_time' => $oneTime['gross'] > 0, + ]; + } + public static function checkNumOfCompProducts($user_abo) { if ($user_abo->is_for === 'me') { - $needNumComp = Yard::instance('shopping')->getNumComp(); - if ($needNumComp > 0) { - $UserAboItems = UserAboItem::where('user_abo_id', $user_abo->id)->where('comp', '>', 0)->get(); - if (count($UserAboItems) === $needNumComp) { - return true; + $needNumComp = Yard::instance(self::INSTANCE)->getNumComp(); + $UserAboItems = UserAboItem::where('user_abo_id', $user_abo->id) + ->where('comp', '>', 0) + ->orderBy('comp') + ->get(); + + if ($UserAboItems->count() < $needNumComp) { + $product = Product::whereActive(true) + ->where('shipping_addon', true) + ->whereJsonContains('show_on', '12') + ->orderBy('pos', 'DESC') + ->first(); + + if (! $product) { + return false; } - // need to add - if (count($UserAboItems) < $needNumComp) { - $product = Product::whereActive(true)->where('shipping_addon', true)->whereJsonContains('show_on', '12')->orderBy('pos', 'DESC')->first(); - for ($i = count($UserAboItems); $i <= $needNumComp; $i++) { - $UserAboItem = UserAboItem::create([ - 'user_abo_id' => $user_abo->id, - 'product_id' => $product->id, - 'comp' => $i + 1, - 'qty' => 1, - 'status' => 1, - ]); - AboItemHistoryService::logSystemCompAdded($user_abo, $UserAboItem); - self::addProductToCart($UserAboItem); + for ($comp = $UserAboItems->count() + 1; $comp <= $needNumComp; $comp++) { + $UserAboItem = UserAboItem::create([ + 'user_abo_id' => $user_abo->id, + 'product_id' => $product->id, + 'comp' => $comp, + 'qty' => 1, + 'status' => 1, + ]); + AboItemHistoryService::logSystemCompAdded($user_abo, $UserAboItem); + self::addProductToCart($UserAboItem); + } + } + + if ($UserAboItems->count() > $needNumComp) { + foreach ($UserAboItems as $UserAboItem) { + if ($UserAboItem->comp > $needNumComp) { + AboItemHistoryService::logSystemCompRemoved($user_abo, $UserAboItem); + $UserAboItem->delete(); } } - // need to remove - if (count($UserAboItems) > $needNumComp) { - foreach ($UserAboItems as $UserAboItem) { - if ($UserAboItem->comp > $needNumComp) { - AboItemHistoryService::logSystemCompRemoved($user_abo, $UserAboItem); - $UserAboItem->delete(); - } - } - foreach (Yard::instance('shopping')->content() as $row) { - if ($row->options->comp > $needNumComp) { - Yard::instance('shopping')->remove($row->rowId); - } + + foreach (Yard::instance(self::INSTANCE)->content() as $row) { + if (($row->options->comp ?? 0) > $needNumComp) { + Yard::instance(self::INSTANCE)->remove($row->rowId); } } } diff --git a/app/Services/Yard.php b/app/Services/Yard.php index 23c1760..ad578cc 100644 --- a/app/Services/Yard.php +++ b/app/Services/Yard.php @@ -177,6 +177,34 @@ class Yard extends Cart $this->calculateShippingPrice(); } + /** + * Höchstes Versandgewicht (in Gramm) des aktuell gesetzten Versandlandes. + * Ergibt sich aus der obersten Gewichtsstufe (max. weight_to). 0 = keine Begrenzung ermittelbar. + */ + public function getMaxWeight(): int + { + $shippingCountry = ShippingCountry::find($this->shipping_country_id); + if (! $shippingCountry || ! $shippingCountry->shipping) { + return 0; + } + + return (int) $shippingCountry->shipping->shipping_prices->max('weight_to'); + } + + /** + * Prüft, ob das Warenkorbgewicht zzgl. des optionalen Zusatzgewichts das + * Maximalgewicht des Versandlandes überschreiten würde. + */ + public function exceedsMaxWeight(int $additionalWeight = 0): bool + { + $maxWeight = $this->getMaxWeight(); + if ($maxWeight <= 0) { + return false; + } + + return ($this->weight() + $additionalWeight) > $maxWeight; + } + public function setShippingCountryWithPrice($shipping_country_id, $shipping_is_for = 'ot-member') { $this->shipping_country_id = $shipping_country_id; @@ -276,9 +304,10 @@ class Yard extends Cart // if(!$shipping_price){ // } } - // default + // default: Über der höchsten Gewichtsstufe die teuerste (höchste) Stufe nehmen, + // nicht die günstigste. Im Normalfall wird das Hinzufügen vorher gestoppt (siehe exceedsMaxWeight()). if (! $shipping_price) { - $shipping_price = $shipping->shipping_prices->first(); + $shipping_price = $shipping->shipping_prices->sortByDesc('weight_to')->first(); } } if ($shipping_price) { diff --git a/database/factories/UserAboOneTimeItemFactory.php b/database/factories/UserAboOneTimeItemFactory.php new file mode 100644 index 0000000..be3aed3 --- /dev/null +++ b/database/factories/UserAboOneTimeItemFactory.php @@ -0,0 +1,32 @@ + + */ +class UserAboOneTimeItemFactory extends Factory +{ + protected $model = UserAboOneTimeItem::class; + + public function definition(): array + { + return [ + 'user_abo_id' => $this->faker->randomNumber(3), + 'product_id' => $this->faker->randomNumber(3), + 'comp' => 0, + 'qty' => $this->faker->numberBetween(1, 5), + 'price' => $this->faker->randomFloat(2, 5, 100), + 'price_net' => $this->faker->randomFloat(3, 4, 90), + 'tax_rate' => 19.00, + 'tax' => $this->faker->randomFloat(3, 1, 20), + 'price_vk_net' => $this->faker->randomFloat(3, 4, 90), + 'discount' => 0, + 'points' => $this->faker->numberBetween(0, 50), + 'status' => 1, + ]; + } +} diff --git a/database/migrations/2026_05_12_113939_add_french_locale_to_trans_languages.php b/database/migrations/2026_05_12_113939_add_french_locale_to_trans_languages.php index cf576d9..4383193 100644 --- a/database/migrations/2026_05_12_113939_add_french_locale_to_trans_languages.php +++ b/database/migrations/2026_05_12_113939_add_french_locale_to_trans_languages.php @@ -2,6 +2,7 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; return new class extends Migration { @@ -10,6 +11,10 @@ return new class extends Migration */ public function up(): void { + if (! Schema::hasTable('trans_languages')) { + return; + } + DB::table('trans_languages')->updateOrInsert( ['language' => 'fr'], [ @@ -25,6 +30,10 @@ return new class extends Migration */ public function down(): void { + if (! Schema::hasTable('trans_languages')) { + return; + } + DB::table('trans_languages') ->where('language', 'fr') ->delete(); diff --git a/database/migrations/2026_06_05_120000_create_user_abo_one_time_items_table.php b/database/migrations/2026_06_05_120000_create_user_abo_one_time_items_table.php new file mode 100644 index 0000000..8b5634e --- /dev/null +++ b/database/migrations/2026_06_05_120000_create_user_abo_one_time_items_table.php @@ -0,0 +1,45 @@ +increments('id'); + $table->unsignedInteger('user_abo_id'); + $table->unsignedInteger('product_id'); + $table->unsignedTinyInteger('comp')->nullable(); + $table->unsignedInteger('qty'); + + $table->decimal('price', 8, 2)->nullable(); + $table->decimal('price_net', 8, 3)->nullable(); + $table->decimal('tax_rate', 5, 2)->nullable(); + $table->decimal('tax', 8, 3)->nullable(); + $table->decimal('price_vk_net', 8, 3)->nullable(); + $table->decimal('discount', 5, 2)->nullable(); + $table->unsignedInteger('points')->nullable(); + + $table->unsignedTinyInteger('status')->index()->default(0); + $table->timestamps(); + $table->softDeletes(); + + $table->foreign('user_abo_id') + ->references('id') + ->on('user_abos') + ->onDelete('cascade'); + + $table->foreign('product_id') + ->references('id') + ->on('products'); + }); + } + + public function down(): void + { + Schema::dropIfExists('user_abo_one_time_items'); + } +}; diff --git a/database/migrations/2026_06_05_120100_add_is_abo_addon_to_shopping_order_items_table.php b/database/migrations/2026_06_05_120100_add_is_abo_addon_to_shopping_order_items_table.php new file mode 100644 index 0000000..6f52701 --- /dev/null +++ b/database/migrations/2026_06_05_120100_add_is_abo_addon_to_shopping_order_items_table.php @@ -0,0 +1,22 @@ +boolean('is_abo_addon')->default(false)->after('comp'); + }); + } + + public function down(): void + { + Schema::table('shopping_order_items', function (Blueprint $table) { + $table->dropColumn('is_abo_addon'); + }); + } +}; diff --git a/database/migrations/2026_06_05_164339_add_confirmation_state_to_user_abo_one_time_items_table.php b/database/migrations/2026_06_05_164339_add_confirmation_state_to_user_abo_one_time_items_table.php new file mode 100644 index 0000000..a290b59 --- /dev/null +++ b/database/migrations/2026_06_05_164339_add_confirmation_state_to_user_abo_one_time_items_table.php @@ -0,0 +1,29 @@ +unsignedInteger('confirmed_qty')->nullable()->after('qty'); + $table->timestamp('confirmed_at')->nullable()->after('confirmed_qty'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('user_abo_one_time_items', function (Blueprint $table) { + $table->dropColumn(['confirmed_qty', 'confirmed_at']); + }); + } +}; diff --git a/dev/2026-06-05/ENTWICKLUNGSPLAN-ABO-EINMALPRODUKTE.md b/dev/2026-06-05/ENTWICKLUNGSPLAN-ABO-EINMALPRODUKTE.md new file mode 100644 index 0000000..7a49bf5 --- /dev/null +++ b/dev/2026-06-05/ENTWICKLUNGSPLAN-ABO-EINMALPRODUKTE.md @@ -0,0 +1,387 @@ +# Entwicklungsplan: Einmalige Produkte zum Abo-Versand hinzufügen + +> **Status:** In Planung · **Erstellt:** 2026-06-05 · **Briefing:** `docs/salescenter/ABO - Bestellungen.md` + +## Dokumentations-Hinweis (verbindlich) + +Dieser Plan wird **Schritt für Schritt** abgearbeitet. Für **jede Phase** gilt: + +- Der **Umsetzungsstatus** in diesem Dokument wird nach Abschluss eines Schritts auf `ERLEDIGT` gesetzt (mit Datum, betroffenen Dateien, Zeilenangaben und kurzer Begründung – analog zu `dev/2026-02-19/plan-wizard-starterpaket.md`). +- **Alle Phasen, Hinweise und Optimierungen** (Abschnitt „Zu beachtende Punkte") werden sauber dokumentiert – auch Abweichungen vom Plan, getroffene Detail-Entscheidungen und Verworfenes (mit Begründung). +- Jede Änderung wird **programmatisch getestet** (Pest), bevor die Phase als erledigt gilt. +- Nach Code-Änderungen läuft `./vendor/bin/pint`. + +--- + +## 1. Ziel & Briefing-Zusammenfassung + +Berater und Endkunden sollen **kurz vor der nächsten Abo-Ausführung** (Zeitfenster X Tage, konfigurierbar) **einmalig** Produkte aus dem **normalen Bestellsortiment** (nicht dem Abo-Sortiment) zu ihrer anstehenden Abo-Lieferung hinzufügen können. + +**Nutzen:** Versandkosten und einen zweiten Versandweg sparen (Nachhaltigkeit). + +Kernpunkte: + +- **Einmalig**, nicht dauerhaft – nach erfolgreicher Abo-Ausführung werden die Einmal-Artikel wieder entfernt; nur die reinen Abo-Produkte bleiben. +- **Getrennte Sortimente:** Abo (`show_on` 12/13) vs. Bestellsortiment (`show_on` 2 = Berater, `show_on` 3 = Endkunde). +- **Zwei Anlaufstellen:** Sales Center `my.mivita.care` (Berater) und Portal `in.mivita.care` (Endkunde). +- **E-Mail-Vorlauf** mit zielgruppenspezifischem Link + Hinweis auf Deckung der Zahlungsweise und Nachhaltigkeit. +- **UI:** Card über den Abo-Produkten → Modal mit Bestellsortiment (inkl. Steuer + Endpreis), `+`-Buttons, zweiter „Warenkorb" mit getrennten Zwischensummen (oben Einmal-Artikel, unten Abo), separate Gesamtsummen-Card (kombinierter Versand nach Gesamtgewicht), sticky Warenkorb-Seitenleiste. +- **Rechnung & Lieferschein:** Eine Gesamtrechnung, Einmal-Artikel klar gekennzeichnet und unterhalb der Abo-Produkte abgegrenzt. + +--- + +## 2. Geklärte Rahmenbedingungen (Stand 2026-06-05) + +| # | Entscheidung | +| -- | ------------ | +| 1 | Zeitfenster-Setting Default = **4 Tage**; Fenster gilt von `next_date − X` bis Ausführung. | +| 2 | **Bei Zahlungsfehler bleiben** Einmal-Artikel erhalten. User **und** Admin können Retry selbst anstoßen. User kann Artikel **jederzeit im Fenster** hinzufügen/entfernen. | +| 3 | Einmal-Artikel **bleiben bestehen** bis zur nächsten **erfolgreichen** Ausführung oder bis sie der User manuell entfernt – auch bei Pause/Verschiebung. | +| 4 | **Preis-Snapshot** beim Hinzufügen (Transparenz: hinzugefügter Preis gilt, nicht Tagespreis). | +| 5 | Deckungsprüfung = **nur Text-Hinweis** (keine API-Abfrage möglich; Fehler kommt erst bei Abbuchung). | +| 6 | Sortiment: **`show_on=2`** (Berater) / **`show_on=3`** (Endkunde), **keine** Min-/Höchstmenge (wie normale Bestellung). | +| 7 | Komplette Versandlogik gilt: Gewicht ↑ → Versand ↑, ggf. **neues Kompensationsprodukt** nötig; im End-Warenkorb berücksichtigt und bei nächster Ausführung **zurückgesetzt**. | +| 8 | Endkunden-Route passt; jeder Kunde hat **nur ein Abo** (kleine IDs). | + +--- + +## 3. Ist-Zustand (Analyse-Ergebnis) + +| Bereich | Status heute | Schlüsseldateien | +| ------- | ------------ | ---------------- | +| Abo-Datenmodell | `UserAbo` + `UserAboItem` (dauerhafte Positionen, `show_on` 12/13) + `UserAboOrder` (Ausführungs-Historie) | `app/Models/UserAbo.php`, `app/Models/UserAboItem.php`, `app/Models/UserAboOrder.php` | +| Ausführung | Cron `user:make_abo_order` (täglich 04:00) → `UserMakeOrder` baut `ShoppingOrder` (`is_abo=1`) aus `user_abo_items`, zahlt via Payone | `app/Console/Commands/UserMakeAboOrder.php`, `app/Cron/UserMakeOrder.php` | +| Cart | Kein eigenes Cart-System – `AboOrderCart` befüllt `Yard::instance('shopping')` aus Items, berechnet Versand nach Gewicht, schreibt `user_abos.amount` | `app/Services/AboOrderCart.php`, `app/Services/Yard.php` | +| Sortiment-Trennung | Über `ProductOrderContext::allowedShowOnIds()` (Berater `2`/Abo `12,13`; Kunde `3`/Abo `12,13`) | `app/Services/ProductOrderContext.php`, `app/Http/Controllers/User/OrderController.php:598-639` | +| „Produkt hinzufügen" (bestehend) | Erhöht **dauerhaft** das Abo-Sortiment (Action `addProduct`) – **nicht** das hier benötigte Verhalten | `app/Http/Controllers/User/AboController.php`, `Portal/AboController.php` | +| Retry | Vorhanden im Admin per Knopfdruck (`AboRetryPaymentService::retry()`), nutzt `UserMakeOrder::makeShoppingOrder()` | `app/Services/AboRetryPaymentService.php`, `app/Http/Controllers/Admin/AboController.php:106` | +| Rechnung/Lieferschein | Flache Iteration über `shopping_order_items` – **keine** Abo-/Einmal-Trennung | `resources/views/pdf/invoice-detail.blade.php`, `resources/views/pdf/delivery-detail.blade.php` | +| Settings | Slug-basiertes `Setting`-Model; „Abo Einstellungen"-Card existiert (`abo-min-duration`) | `app/Models/Setting.php`, `resources/views/admin/settings/index.blade.php:63-75` | +| E-Mail-Vorlauf | **Existiert nicht** | – | +| Domains | `Util::getMyMivitaUrl()` (Berater) / `Util::getMyMivitaPortalUrl()` (Endkunde) | `app/Services/Util.php` | + +--- + +## 4. Architektur-Entscheidungen + +### 4.1 Eigene Tabelle `user_abo_one_time_items` (mit Preis-Snapshot) + +Bewusst **getrennt** von `user_abo_items`, damit Einmal-Artikel nie in die dauerhafte Abo-Logik (Provision, Mindestlaufzeit, History, num_comp der Abo-Items) geraten und sauber gelöscht werden können. + +**Spalten:** `id`, `user_abo_id` (FK, cascade), `product_id` (FK), `comp` (für ggf. auto. Kompensationsprodukte), `qty`, `price`, `price_net`, `tax_rate`, `tax`, `price_vk_net`, `discount`, `points`, `status`, `created_at`, `updated_at`, `deleted_at` (SoftDeletes). + +Preis-Felder werden **beim Hinzufügen** als Snapshot befüllt (Entscheidung #4). + +### 4.2 Kennzeichnung in `shopping_order_items` + +Neue Spalte **`is_abo_addon`** (boolean, default `false`). Es bleibt **eine** Bestellung/Rechnung; die Trennung in Rechnung/Lieferschein erfolgt über dieses Flag (Abo-Produkte oben, Einmal-Artikel abgegrenzt darunter). + +### 4.3 Kombinierter Versand, getrennte Zwischensummen + +Einmal-Artikel + Abo-Items landen im **selben `Yard`-Cart** → Versand wird automatisch über das **Gesamtgewicht** berechnet (das ist der Nutzen des Features). Einmal-Artikel tragen im Cart die Option **`abo_addon => true`** → ermöglicht getrennte Zwischensummen-Berechnung in der Live-Ansicht ohne DB-Roundtrip. + +### 4.4 `user_abos.amount` entkoppeln + +`AboOrderCart::makeOrderYard()` überschreibt aktuell `user_abos.amount` bei **jedem** Aufruf (auch beim bloßen Ansehen). `amount` muss weiterhin nur den **reinen Abo-Betrag** enthalten. Der **kombinierte** Betrag (inkl. Einmal-Artikel) wird nur für die Abbuchung im Cron/Retry berechnet, nicht persistiert. + +### 4.5 Zentrale Purge-Logik + +Eine Methode „Einmal-Artikel des Abos löschen", aufgerufen **nur bei erfolgreicher** Ausführung – sowohl im Cron-Erfolgszweig (`UserMakeOrder`/`UserMakeAboOrder` → `updateAbo`) als auch in `AboRetryPaymentService::markAboSuccess()`. Bei Fehler: **behalten**. + +### 4.6 Zeitfenster-Setting + +Neuer Slug **`abo-onetime-window-days`** (Typ `int`, Default `4`) in der „Abo Einstellungen"-Card. Steuert Sichtbarkeit der Card/des Modals **und** den Versandzeitpunkt der Reminder-Mail. **Unabhängig** von den hartkodierten Lock-Fristen in `AboRepository` (siehe Punkt 9.2). + +--- + +## 5. Betroffene Dateien (Übersicht) + +| Datei | Änderung | +| ----- | -------- | +| `database/migrations/...create_user_abo_one_time_items_table.php` | **NEU** – Tabelle mit Snapshot-Feldern | +| `database/migrations/...add_is_abo_addon_to_shopping_order_items_table.php` | **NEU** – Flag-Spalte | +| `app/Models/UserAboOneTimeItem.php` | **NEU** – Model + Relationen | +| `database/factories/UserAboOneTimeItemFactory.php` | **NEU** – Factory | +| `app/Models/UserAbo.php` | Relation `one_time_items()` | +| `app/Models/ShoppingOrderItem.php` | `is_abo_addon` fillable/cast, Scopes | +| `app/Services/AboHelper.php` | `isOneTimeWindowOpen()`, Purge-Helper | +| `app/Services/AboOrderCart.php` (oder neuer `AboOneTimeCart`) | Einmal-Artikel in Yard laden, getrennte Zwischensummen, num_comp inkl. Gesamtgewicht | +| `app/Http/Controllers/User/AboController.php` | Actions `addOneTime` / `updateOneTime` / `removeOneTime` / User-Retry | +| `app/Http/Controllers/Portal/AboController.php` | dieselben Actions für Endkunde | +| `app/Http/Controllers/ModalController.php` | Modal-Branch für Einmal-Produkt-Auswahl | +| `app/Http/Requests/...` | **NEU** – Form Requests für die Actions | +| `app/Cron/UserMakeOrder.php` | Einmal-Artikel mit in Bestellung, `is_abo_addon=true`, kombinierter Betrag | +| `app/Console/Commands/UserMakeAboOrder.php` | Purge nach Erfolg (zentral) | +| `app/Services/AboRetryPaymentService.php` | Purge im `markAboSuccess()` | +| `app/Console/Commands/AboSendExecutionReminder.php` | **NEU** – Reminder-Command | +| `app/Console/Kernel.php` | Schedule-Eintrag Reminder | +| `app/Mail/MailAboExecutionReminder.php` | **NEU** – Mailable | +| `resources/views/emails/abo_execution_reminder.blade.php` | **NEU** – Mail-Template | +| `resources/views/user/abo/detail.blade.php` | Card + zweite Liste + Summen + sticky Sidebar | +| `resources/views/portal/abo/my_abo.blade.php` | dito für Endkunde | +| `resources/views/admin/abo/_order_onetime*.blade.php` | **NEU** – Einmal-Artikel-Liste/Summen | +| `resources/views/user/abo/modal_abo_onetime_products.blade.php` | **NEU** – Modal Bestellsortiment | +| `public/js/iq-modal-cart.js` | JS für neue Actions | +| `resources/views/pdf/invoice-detail.blade.php` | Gruppierung Abo / Einmal | +| `resources/views/pdf/delivery-detail.blade.php` | Gruppierung Abo / Einmal | +| `resources/views/admin/settings/index.blade.php` | Setting `abo-onetime-window-days` | +| `resources/lang/{de,en,es,fr}/abo.php`, `email.php` | Übersetzungen | +| `tests/Feature/...`, `tests/Unit/...` | **NEU** – Tests | + +--- + +## 6. Phasenplan & Umsetzungsstatus + +### Phase 1 – Datenmodell & Settings · `ERLEDIGT (2026-06-05)` + +- [x] 1.1 Migration `user_abo_one_time_items` (inkl. Snapshot-Felder, FKs, SoftDeletes). +- [x] 1.2 Model `UserAboOneTimeItem` (fillable, casts, Relationen `product()`, `user_abo()`) + Factory. +- [x] 1.3 Migration `is_abo_addon` (boolean, default false) auf `shopping_order_items`. +- [x] 1.4 `ShoppingOrderItem`: `is_abo_addon` in fillable/casts, Scopes `aboItems()` / `addonItems()`. +- [x] 1.5 Relation `one_time_items()` (hasMany) auf `UserAbo`. +- [x] 1.6 Setting `abo-onetime-window-days` (int, Default 4) in `admin/settings/index.blade.php` (Abo-Card). +- [x] 1.7 `AboHelper::isOneTimeWindowOpen(UserAbo $abo): bool` (vergleicht `next_date` mit `now()` + Setting). +- [x] 1.8 Tests: Window-Logik, Setting-Default/Custom, Model-Cast/Relation, Scopes. + +**Doku (Stand 2026-06-05):** + +- **1.1** `database/migrations/2026_06_05_120000_create_user_abo_one_time_items_table.php` – Tabelle mit Snapshot-Spalten (`price`, `price_net`, `tax_rate`, `tax`, `price_vk_net`, `discount`, `points`), `comp`, `qty`, `status`, `timestamps`, `softDeletes`. FK `user_abo_id` → `user_abos` (cascade), `product_id` → `products`. Spalten-Typen analog `shopping_order_items`. +- **1.2** `app/Models/UserAboOneTimeItem.php` (HasFactory + SoftDeletes, Casts/Fillable, Relationen `product()`/`user_abo()`, `getFormattedPrice()`/`getFormattedTotalPrice()`). Factory `database/factories/UserAboOneTimeItemFactory.php`. +- **1.3** `database/migrations/2026_06_05_120100_add_is_abo_addon_to_shopping_order_items_table.php` – `boolean is_abo_addon default(false) after('comp')`. +- **1.4** `app/Models/ShoppingOrderItem.php` – `is_abo_addon` in fillable + Cast `bool`; Scopes `scopeAboItems()` (`is_abo_addon = false`) und `scopeAddonItems()` (`is_abo_addon = true`). +- **1.5** `app/Models/UserAbo.php` – `one_time_items()` (hasMany) + Docblock `@property-read`. +- **1.6** `resources/views/admin/settings/index.blade.php` – Feld „Zeitfenster für einmalige Produkte vor Ausführung (Tage)" (Slug `abo-onetime-window-days`, Typ `int`) in der Abo-Card, inkl. Hilfetext (Standard 4). Speicherung über bestehenden `SettingController@store`-Mechanismus. +- **1.7** `app/Services/AboHelper.php` – Konstante `DEFAULT_ONETIME_WINDOW_DAYS = 4`, `getOneTimeWindowDays()` (liest Setting, Fallback Default bei 0/leer), `isOneTimeWindowOpen(UserAbo)` (rein zeitliche Prüfung: `next_date` vorhanden, nicht in der Vergangenheit, `diffInDays <= Fenster`). Abo-Zustand (active/status) prüfen die Aufrufer. +- **1.8** `tests/Feature/AboOneTimeWindowTest.php` – 13 Tests, alle grün (Window-Grenzfälle Tag X / X+1 / Vergangenheit / kein `next_date`, Setting-Default/Custom, Model-Cast + Relation-Typ, Scope-SQL + bool-Cast). +- **Pint:** ausgeführt (`--dirty`), `ShoppingOrderItem.php` formatiert. + +**WICHTIG – Nebenbefund / Fix (siehe auch Abschnitt 9.7):** Die RefreshDatabase-Testsuite war auf SQLite **generell** kaputt: Migration `2026_05_12_113939_add_french_locale_to_trans_languages.php` setzt die Tabelle `trans_languages` voraus, die per Migration nie erzeugt wird (existiert nur in der echten MySQL-DB). Auch bestehende Tests (`AboRepositoryDateChangeTest`) schlugen dadurch fehl. Fix: `up()`/`down()` mit `Schema::hasTable('trans_languages')`-Guard versehen (produktionssicher, da Tabelle dort existiert; in Tests wird übersprungen). Dadurch läuft die gesamte RefreshDatabase-Suite wieder. + +--- + +### Phase 2 – Backend-Logik (Berater + Endkunde) · `ERLEDIGT (2026-06-05)` + +- [x] 2.1 Cart-Befüllung erweitern: Einmal-Artikel mit **Snapshot-Preis** + Option `abo_addon=true` zusätzlich in den Yard. +- [x] 2.2 Getrennte Zwischensummen-Methode (Einmal vs. Abo). +- [x] 2.3 Kompensationsprodukt-Logik auf **Gesamtgewicht inkl. Einmal-Artikel** (über Yard-Aufrufreihenfolge). +- [x] 2.4 AJAX-Action `oneTime` (add/update/remove) in `User\AboController` **und** `Portal\AboController` – Zeitfenster-Guard, Sortiment-Filter, Snapshot, Berechtigungs-Check. +- [x] 2.5 Form Request `AboOneTimeItemRequest` + Übersetzungen (DE/EN/ES/FR). +- [x] 2.6 Tests (Service add/update/remove, Sortiment-Guard, Snapshot, getrennte Summen, FormRequest-Regeln). + +**Doku (Stand 2026-06-05):** + +- **2.1** `app/Services/AboOrderCart.php` → `addOneTimeItemsToYard(UserAbo)`: lädt `one_time_items` mit eingefrorenem Snapshot-Preis und Cart-Option `abo_addon=true` (+ `one_time_item_id`, `weight` live, `points`/`tax_rate` aus Snapshot) in `Yard::instance('shopping')`, danach `reCalculateShippingPrice()`. **Verändert `amount` nicht** (wird nach `makeOrderYard()` aufgerufen → 9.1 erfüllt). +- **2.1/Snapshot** `AboOrderCart::buildOneTimeSnapshot(Product, UserAbo)`: berechnet `price` (Berater: `getPriceWith(tax_free,true,…)`, Kunde: `round(getPriceWith(tax_free,false,…),1)`), `price_net`, `tax_rate`, `tax`, `price_vk_net`, `discount` (Berater-Marge), `points` – konsistent zu `addProductToCart()`. +- **2.2** `AboOrderCart::getSplitSummary()`: iteriert Yard-Content, trennt per `options->abo_addon` in `abo`/`one_time` (net/gross/tax) und liefert kombinierten Versand + `total_with_shipping` + `has_one_time`. +- **2.3** Reihenfolge in der Action: `initYard → makeOrderYard → addOneTimeItemsToYard → checkNumOfCompProducts`. Da die Einmal-Artikel **vor** `checkNumOfCompProducts` im Yard liegen, berücksichtigt `getNumComp()` automatisch das Gesamtgewicht. Beim Ausführen (Phase 4) wird ohne Einmal-Artikel neu gerechnet → Reset. +- **2.4** `User\AboController@oneTime()` + `Portal\AboController@oneTime()`: prüfen `isOneTimeWindowOpen()` (403 wenn zu), delegieren an gemeinsamen Service, bauen Yard neu auf und liefern JSON `{response, message, summary, one_time_items}`. Berechtigung via `checkPermissions()`/`checkPortalPermission()`. +- **Service** `app/Services/AboOneTimeService.php`: gemeinsame `handleAction()` (add/update/remove) für beide Controller (DRY). Sortiment-Guard via `ProductOrderContext::allowedShowOnIds(false, me|ot-customer)` → `['2']`/`['3']`. Mengen-Clamp 1–100. Fremd-Item-Schutz über `user_abo_id`-Filter. +- **Routen:** `user_abos_onetime` in `routes/domains/crm.php` (`POST /user/abos/onetime/{view}/{id}`) und `routes/domains/portal.php` (`POST portal/my-subscriptions/onetime/{view}/{id}`). +- **2.5** `app/Http/Requests/Abo/AboOneTimeItemRequest.php` (array-Regeln, `required_if`-Logik, lokalisierte Messages). Neue Keys in `resources/lang/{de,en,es,fr}/abo.php`: `onetime_window_closed`, `onetime_product_not_allowed`, `onetime_action_required`, `onetime_action_invalid`, `onetime_qty_required`. +- **2.6** `tests/Feature/AboOneTimeServiceTest.php` – 10 Tests grün (gesamt mit Phase 1: **23 grün**). +- **Pint:** ausgeführt; ungenutzte Imports entfernt. + +**Abweichungen / Entscheidungen (dokumentiert):** + +1. **Gemeinsamer Service statt inline-Duplikat:** Add/Update/Remove liegen in `AboOneTimeService`, nicht doppelt in beiden Controllern (die bestehende `update()`-Multiplex-Logik wurde bewusst nicht angefasst). +2. **Dedizierter Endpoint statt Multiplex-`update()`:** Damit der `FormRequest` sauber greift (Auto-Validation), bekommen die Einmal-Aktionen eine eigene Route/Methode `oneTime`, statt sie in die action-gemultiplexte `update()` zu zwängen (dort sind keine FormRequests im Einsatz). +3. **Datatable + Modal für die Produktauswahl** (normales Sortiment `show_on` 2/3) sowie die JS-Anbindung sind **UI** und gehören zu **Phase 3** – hier bewusst noch nicht umgesetzt. +4. **Teilabdeckung Tests:** Die voll integrierte kombinierte Versand-/`num_comp`-Berechnung (mit echtem Yard-Init für `me`/`ot`) wird in **Phase 4** (Ausführung) getestet; Phase 2 deckt Split-Mathematik (`getSplitSummary`) und Snapshot isoliert ab. + +--- + +### Phase 3 – Frontend / Views · `ERLEDIGT (2026-06-05)` + +- [x] 3.1 Neue Card oberhalb der Abo-Produkte in `user/abo/detail.blade.php` + `portal/abo/my_abo.blade.php` (nur wenn Fenster offen) inkl. Nachhaltigkeits-/Versand-Spar- und Deckungs-Hinweis (Text). +- [x] 3.2 Modal `modal_abo_onetime_products.blade.php` (Yajra-Datatable Sortiment 2/3, Spalten Steuer + berechneter Endpreis, `+`-Button pro Zeile). +- [x] 3.3 Übersicht: zweite Produktliste (Einmal) mit eigener Zwischensumme **oben**, Abo-Liste mit Zwischensumme **unten** (`split_mode`). +- [x] 3.4 Separate Gesamtsummen-Card unter beiden Listen (Zwischensumme Einmal + Zwischensumme Abo + Versand nach Gesamtgewicht + Steuer + Gesamtsumme). +- [x] 3.5 Sticky Warenkorb-Seitenleiste (Bootstrap 4 `sticky-top`) mit Detail-Zusammenfassung. +- [x] 3.6 Eigenes JS-Modul `iq-abo-onetime.js` für die neuen Actions (Mengen +/-, Entfernen, Modal laden) – getrennt von `iq-modal-cart.js` (eigener Endpoint, eigene Holder). +- [ ] 3.7 Visuelle Prüfung (Berater + Endkunde) – durch den User vorzunehmen; **kein** Asset-Build nötig (JS liegt in `public/js`, via `asset()` eingebunden). + +**Doku (2026-06-05):** + +- **Neue View-Partials** (`resources/views/admin/abo/`): + - `_order_onetime.blade.php` – Card mit Überschrift, Info-/Deckungs-/Nachhaltigkeits-Hinweis, „Einmaliges Produkt hinzufügen"-Button (Modal-Trigger `data-action="abo-add-onetime"`), Holder `#insert_show_onetime_order`. + - `_order_onetime_show.blade.php` – Einmal-Artikel-Liste (Bild, Name + „Einmalig"-Badge, Mengen-Stepper, Entfernen, Position­spreis aus Snapshot) + Zwischensumme Einmal aus `$summary`. + - `_order_combined_summary.blade.php` – Gesamtsummen-Card: Zwischensumme Einmal + Zwischensumme Abo + Lieferland + Versand + (Netto/Steuer) + Gesamtsumme (alle aus `Yard::instance('shopping')`-Kombiwerten + `$summary`). +- **Modal:** `resources/views/user/abo/modal_abo_onetime_products.blade.php` – DataTable über `user_abo_onetime_datatable`, `+`-Button-Spalte (`.add-onetime-product-basket`). +- **`_order_abo_show.blade.php`:** Neuer optionaler Schalter `$split_mode` → zeigt im Split-Modus **nur** die Abo-Zwischensumme (aus `$summary['abo']['gross']`), Versand/Steuer/Gesamt entfallen (wandern in die Gesamtsummen-Card). Default `false` ⇒ bestehende Aufrufer unverändert. `_order_abo.blade.php` reicht `$split_mode`/`$summary` durch (mit Defaults). +- **Nachtrag 2026-06-05:** Im Split-Modus zeigt `_order_abo_show.blade.php` zusätzlich zur Abo-Zwischensumme jetzt auch den **Gesamtbetrag der nächsten Lieferung** (`$summary['total_with_shipping']`) analog zur Einmalprodukt-Liste. Versand-/Steuerdetailzeilen bleiben weiterhin in der seitlichen Gesamtsummen-Card, damit die Abo-Liste nicht wieder den vollständigen Warenkorb dupliziert. +- **Layout** `user/abo/detail.blade.php` + `portal/abo/my_abo.blade.php`: Bei offenem Fenster zweispaltiges `row` → links (`col-lg-8`) Einmal-Card + Abo-Card (split) + Comp-Produkte, rechts (`col-lg-4`) sticky Gesamtsummen-Card (`sticky-top`, `top:1rem`). Bei geschlossenem Fenster unveränderter Single-Column-Aufbau. +- **JS** `public/js/iq-abo-onetime.js` (neu, eingebunden in beide Detail-Views, init `IqAboOneTime.init()`): postet an `data-onetime-route` (`user_abos_onetime`) mit `action add|update|remove`; rendert `#insert_show_onetime_order`, `#insert_show_products_order` (Abo-Split) und `#combined_summary_card` aus den HTML-Feldern der JSON-Antwort neu; Fehler-Handling über `#insert_onetime_error_message`. +- **Controller (User + Portal):** + - `oneTimeDatatable($id)` neu – Sortiment `show_on=2` (Berater/`me`) bzw. `show_on=3` (Endkunde/`ot`/Portal), Spalten inkl. Netto-/Brutto-Endpreis. + - `oneTime()` liefert zusätzlich `html_onetime`, `html_abo` (split), `html_summary` für das Live-Re-Rendering. + - `detail()`/`myAbo()` legen bei offenem Fenster die Einmal-Artikel mit in den Yard (`addOneTimeItemsToYard`) und übergeben `one_time_window_open` + `summary`. + - `ModalController@load` (CRM) und `Portal\AboController@modalLoad` (Portal): Branch `abo-add-onetime` → Onetime-Modal. +- **Routen:** `user_abo_onetime_datatable` (CRM `routes/domains/crm.php`, Portal `routes/domains/portal.php`). +- **Übersetzungen** (`de/en/es/fr/abo.php`): `onetime_card_hl`, `onetime_card_info`, `onetime_important_hl`, `onetime_coverage_hint`, `onetime_reset_hint`, `add_onetime_product`, `onetime_subtotal`, `onetime_next_delivery_total`, `abo_subtotal`, `abo_next_delivery_total`, `onetime_empty`, `onetime_badge`, `combined_summary_hl`. +- **Tests:** `tests/Feature/AboOneTimeViewTest.php` (4 Render-Tests: Einmal-Liste mit Position/Zwischensumme, Leer-Hinweis, kombinierte Gesamtsumme, Abo-Liste im Split-Modus). Gemeinsame Test-Helper (`makeShopEnv/makeMeAbo/makeProduct`) nach `tests/Pest.php` verschoben (guarded), Duplikate aus `AboOneTimeServiceTest.php` entfernt. **27 Tests grün** (Phase 1–3). Pint: pass. +- **Sticky-Sidebar (Nachtrag 2026-06-05):** Bootstrap-`sticky-top` wurde durch eine **JS-gesteuerte Lösung** (`IqAboOneTime.initSticky/updateSticky` in `iq-abo-onetime.js`) ersetzt, da `position: sticky` in diesem Appwork-Layout durch Vorfahren-`overflow` unzuverlässig ist. Die Summen-Card (`#combined_summary_card` in `#combined_summary_col`) wird per `position: fixed` am oberen Rand angeheftet (Abstand 16 px) und **maximal bis zum Ende der Spalte** mitgeführt (Clamp `colRect.bottom − cardHeight`). Scroll-Listener im Capture-Modus (erfasst beliebige Scroll-Container), `requestAnimationFrame`-Throttle, Reset unter `lg` (einspaltig) und Re-Berechnung nach jedem Re-Render. Views nutzen statt `sticky-top` die Klasse `js-abo-sticky`. +- **Bestätigung Einmalprodukte (Nachtrag 2026-06-05):** `UserAboOneTimeItem` wurde um `confirmed_qty`/`confirmed_at` erweitert. `AboOneTimeService` unterstützt `confirm`/`discard`: Bestätigen friert den aktuellen Stand kostenpflichtig ein (`status=1`), Verwerfen stellt den zuletzt bestätigten Stand wieder her oder entfernt unbestätigte neue Artikel endgültig. Die Einmalprodukt-Liste zeigt unten eine rechtliche Bestätigungsfläche mit Zwischensumme, Gesamtbetrag der nächsten Lieferung, AGB-/Widerrufs-Links und den Buttons „Änderungen verwerfen" / „Zahlungspflichtig aktualisieren". Nach Bestätigung verschwinden die Buttons und es erscheint ein grüner Erfolgshinweis; jede spätere Mengen-/Produktänderung setzt den Block wieder auf den ungespeicherten Zustand. +- **Abo-Erhöhungsmodal (Nachtrag 2026-06-05):** Das bestehende Plus-Modal für dauerhafte Abo-Mengenerhöhungen wurde in `resources/views/admin/abo/_confirm_add_modal.blade.php` zentralisiert und rechtlich geschärft: Titel „Abo-Menge erhöhen", Info-Box, Artikel-/Mengenänderung, zusätzliche Kosten pro Lieferung, neuer Abo-Gesamtbetrag, Wirksamkeit zur nächsten Lieferung sowie AGB-/Widerrufs-Links. Primärer Button: „Zahlungspflichtig aktualisieren". Dieses Modal ist die aktuell umgesetzte aktive Zustimmung für dauerhafte Abo-Änderungen. +- **Gesamtsummen-Card (Nachtrag 2026-06-05):** `_order_combined_summary.blade.php` wurde optisch hervorgehoben: Primary-Header mit weißem Titel, heller Card-Body und weiße Endsumme-Zeile. Die Card bleibt die maßgebliche Stelle für Versand, Steuer und Gesamtsumme der kombinierten nächsten Lieferung. +- **Optimierung/Abweichung:** Statt `iq-modal-cart.js` zu überladen (Briefing 3.6) wurde ein **eigenes** JS-Modul gebaut – sauberere Trennung, da Einmal-Artikel einen eigenen Endpoint, eigene Holder und eine eigene Re-Render-Logik haben; Risiko für den bestehenden Abo-Warenkorb wird vermieden. +- **Comp-Produkte (Nachtrag 2026-06-05):** Der Comp-Holder wird bei Einmal-AJAX-Aktionen mit aktualisiert; zusätzliche Kompensationsprodukte, die erst durch Einmal-/Zusatzprodukte nötig werden, erhalten einen sichtbaren Hinweis. Der Switch-Rebuild berücksichtigt Einmal-Artikel, damit eine Auswahl nicht mehr verschwindet. +- **Tests (Nachtrag 2026-06-05):** `tests/Feature/AboOneTimeServiceTest.php`, `tests/Feature/AboOneTimeViewTest.php` und `tests/Feature/CartMaxWeightTest.php` wurden um Confirmation-State, Split-Summen, Modal-Inhalte, Comp-Hinweis und Maximalgewicht erweitert. Zuletzt geprüft: `./vendor/bin/pint --dirty --format agent` und `php artisan test --compact tests/Feature/AboOneTimeViewTest.php` (8 Tests / 28 Assertions grün). +- **Offene Punkte für Phase 4/Folgearbeit:** Die technische Ausführung bleibt offen: Einmalprodukte in Cron/Retry übernehmen, `is_abo_addon` beim Schreiben der `ShoppingOrderItem` setzen, Purge nur nach erfolgreicher Zahlung, Rechnung/Lieferschein-Gruppierung und Reminder-Mail. + +--- + +### Phase 4 – Ausführung, Retry & Aufräumen · `OFFEN` + +- [ ] 4.1 `UserMakeOrder::makeShoppingOrder()`: Einmal-Artikel mit in den Yard; beim Anlegen der `ShoppingOrderItem` `is_abo_addon=true` setzen; Zahlbetrag = kombiniert. +- [ ] 4.2 Zentrale Purge-Methode bei **Erfolg** aufrufen (Cron-Erfolg **und** `AboRetryPaymentService::markAboSuccess()`); bei Fehler **behalten** (Entscheidung #2/#3). +- [ ] 4.3 `user_abos.amount` entkoppeln – reiner Abo-Betrag bleibt unverfälscht (Punkt 9.1). +- [ ] 4.4 User-seitiges Retry: Route + Action im Sales Center (`User\AboController`) und Portal (`Portal\AboController`) → `AboRetryPaymentService::retry()` mit Berechtigungs-Check; Admin-Retry bleibt bestehen. +- [ ] 4.5 num_comp bei Ausführung zurücksetzen (Einmal-Artikel raus → Kompensationsprodukte neu/zurück). +- [ ] 4.6 Tests: Cron setzt `is_abo_addon` korrekt; Purge nur bei Erfolg; Behalten bei Fehler; User-Retry inkl. Einmal-Artikel; `amount` unverfälscht. + +**Doku:** _(nach Umsetzung ausfüllen)_ + +--- + +### Phase 5 – Rechnung & Lieferschein · `OFFEN` + +- [ ] 5.1 `invoice-detail.blade.php`: Positionen nach `is_abo_addon` gruppieren – Abo oben, abgegrenzter Block „Einmalig hinzugefügte Artikel" darunter. +- [ ] 5.2 `delivery-detail.blade.php`: gleiche Gruppierung/Abgrenzung. +- [ ] 5.3 Übersetzungen DE/EN/ES/FR für Block-Überschriften. +- [ ] 5.4 Tests/Sichtprüfung: PDF mit gemischter Bestellung (Abo + Einmal) korrekt gruppiert. + +**Doku:** _(nach Umsetzung ausfüllen)_ + +--- + +### Phase 6 – Reminder-E-Mail · `OFFEN` + +- [ ] 6.1 Mailable `MailAboExecutionReminder` + Template (DE/EN/ES/FR): Ausführung in X Tagen, Deckung prüfen, jetzt Produkte mitnehmen / Nachhaltigkeit. +- [ ] 6.2 Link je `is_for`: `me` → `getMyMivitaUrl()` + `user/abos/detail/me/{id}`; `ot` → `getMyMivitaPortalUrl()` + Portal-Abo-Route. +- [ ] 6.3 Command `abo:send-execution-reminder`: selektiert Abos mit `next_date = heute + abo-onetime-window-days`, `active`, `status=2`; Duplikat-Schutz pro Zyklus. +- [ ] 6.4 Schedule-Eintrag in `app/Console/Kernel.php`. +- [ ] 6.5 Tests: Selektion, Link-Wahl Berater/Endkunde, Duplikat-Schutz. + +**Doku:** _(nach Umsetzung ausfüllen)_ + +--- + +### Phase 7 – Gesamttests & Abschluss · `OFFEN` + +- [ ] 7.1 End-to-End-Feature-Tests über alle Phasen. +- [ ] 7.2 `./vendor/bin/pint` über alle geänderten Dateien. +- [ ] 7.3 Vollständige Testsuite (nach Rücksprache) laufen lassen. +- [ ] 7.4 Abschluss-Dokumentation in diesem File aktualisieren. + +**Doku:** _(nach Umsetzung ausfüllen)_ + +--- + +## 7. Datenmodell-Detail (Referenz) + +`user_abo_one_time_items` (Snapshot-Felder wie `shopping_order_items`, vgl. `database/migrations/2019_02_23_163724_create_shopping_order_items_table.php`): + +``` +id BIGINT/INT PK +user_abo_id INT FK -> user_abos (cascade) +product_id INT FK -> products +comp TINYINT nullable (auto. Kompensationsprodukte) +qty INT +price DECIMAL(8,2) nullable (Snapshot) +price_net DECIMAL(8,3) nullable (Snapshot) +tax_rate DECIMAL(5,2) nullable (Snapshot) +tax DECIMAL(8,3) nullable (Snapshot) +price_vk_net DECIMAL(8,3) nullable (Snapshot) +discount DECIMAL(5,2) nullable (Snapshot) +points INT nullable (Snapshot) +status TINYINT default 0 +created_at / updated_at / deleted_at +``` + +--- + +## 8. Test-Strategie + +- **Pest Feature-Tests** als Schwerpunkt (Boost-/Projekt-Regeln). Pro Phase mindestens Happy-Path + relevante Fehler-/Grenzpfade. +- Factories für `UserAbo`, `UserAboItem`, `UserAboOneTimeItem`, `Product` (mit `show_on`, `weight`). +- Payone/Zahlung in Ausführungs-/Retry-Tests **mocken**. +- Zeit-/Datums-Tests mit `Carbon::setTestNow()` für das Fenster. + +--- + +## 9. Zu beachtende Punkte / Ungereimtheiten / Optimierungen + +> Diese Punkte sind **verbindlich** zu berücksichtigen und im jeweiligen Phasen-Doku-Abschnitt zu dokumentieren. + +**9.1 `user_abos.amount` wird in `AboOrderCart::makeOrderYard()` bei jedem Aufruf überschrieben.** +Beim Einbinden der Einmal-Artikel in denselben Yard würde `amount` verfälscht. → `amount` weiterhin nur aus reinen Abo-Items; kombinierter Betrag separat für die Abbuchung berechnen (Architektur 4.4). + +**9.2 Konflikt mit Lock-Fristen in `AboRepository`.** +`LOCK_DAYS_CHANGE = 10` / `LOCK_DAYS_PAUSE_CANCEL = 3` (hartkodiert) sperren Abo-Änderungen im Zeitfenster. Einmal-Artikel sind eine **eigene** Aktion und dürfen davon **nicht** betroffen sein → eigener Guard nur über `abo-onetime-window-days`. + +**9.3 Kompensationsprodukte (`num_comp`) sind gewichts-/versandabhängig.** +Mehr Gewicht durch Einmal-Artikel kann ein zusätzliches Kompensationsprodukt nötig machen (Entscheidung #7). Berechnung muss Gesamtgewicht einbeziehen **und** bei nächster Ausführung zurückgesetzt werden. + +**9.4 Rechnung folgt asynchron über Payone-Callback, nicht im Cron.** +Da `is_abo_addon` bereits beim Anlegen der `shopping_order_items` gesetzt wird und die Snapshot-Daten dauerhaft in der `ShoppingOrderItem` liegen, bleibt die Rechnung korrekt – auch nachdem `user_abo_one_time_items` gelöscht wurden (Reihenfolge Purge vs. Rechnung unkritisch). + +**9.5 Cart-Option `abo_addon` zusätzlich zum DB-Flag.** +Für getrennte Zwischensummen in der Live-Ansicht ohne DB-Roundtrip. + +**9.6 Versandkostenfrei-Logik bleibt gültig** (`no_free_shipping`, `free_shipping_consultant`) – Einmal-Artikel verhalten sich wie normale Bestellpositionen (Entscheidung #7). + +**9.7 Test-Umgebung: `trans_languages`-Migration (in Phase 1 gefixt).** +Migration `2026_05_12_113939_add_french_locale_to_trans_languages.php` setzte eine nur in MySQL existierende Tabelle voraus → jeder RefreshDatabase-Test (SQLite `:memory:`) schlug fehl. Behoben durch `Schema::hasTable()`-Guard. Produktionssicher. Falls künftig weitere Migrationen Daten in nicht per Migration erzeugte Tabellen schreiben, gleiches Muster anwenden. + +**9.8 Eigene Warenkorb-Instanz für den Abo-Flow (`AboOrderCart::INSTANCE = 'abo'`).** +Zuvor nutzten der reguläre Bestell-Flow (`OrderController`) **und** der Abo-Detail-/Bearbeitungs-Flow (`AboOrderCart`) dieselbe Session-Instanz `Yard::instance('shopping')`. Bei zwei offenen Fenstern (Abo + Bestellung) vermischten sich die Warenkörbe, da der Cart pro Session nur unter dem Instanz-Namen gespeichert wird. → Abo-Flow auf dedizierte Instanz `'abo'` umgestellt (getrennt von `'shopping'` und der Abo-Anlage-Instanz `'subscription'`). Betroffen: `AboOrderCart` (inkl. `UserService::setInstance`), Cron `UserMakeOrder`, `User\AboController`/`Portal\AboController` (Datatables + `comp_product`-Render via `cart_instance`), `UserAboItem::getPrice()`, alle Abo-Views/Partials (`detail`, `my_abo`, `admin/abo/detail`, `_order_abo_show`, `_order_combined_summary`) sowie das geteilte Partial `comp_product` (jetzt mit `$cart_instance`-Parameter, Default `'shopping'`). `Yard::setTax()` arbeitet auf der zuletzt via `instance()` gesetzten Instanz und folgt der Umstellung automatisch. + +**9.9 Versandkosten-Anzeige bei steuerbefreiten Kunden (Anzeige-Konsistenz).** +In den Abo-Übersichten (`_order_abo_show`, `_order_combined_summary`) wurde die Versandzeile per `shipping()` (**brutto**) ausgegeben, obwohl steuerbefreite Kunden laut `Yard::totalWithShipping()` den **Netto**-Versand zahlen → Versandzeile stimmte nicht mit der (Netto-)Gesamtsumme überein. Fix: bei `getUserTaxFree()` jetzt `shippingNet()` anzeigen, sonst weiterhin `shipping()`. Der Bestell-Warenkorb (`yard_view_form`) war bereits korrekt (durchgängig netto). Abrechnung unverändert (steuerbefreit = Netto-Versand). + +**9.10 Maximalgewicht-Sperre für alle Warenkörbe.** +Die Versandkosten sind in Gewichtsstufen (`shipping_prices.weight_from`/`weight_to`) definiert. Bisher fiel die Berechnung bei Überschreitung der höchsten Stufe auf `shipping_prices->first()` (= **günstigste** Stufe) zurück – fachlich falsch. → Zwei Korrekturen: +1. **Fallback-Fix** in `Yard::calculateShippingPrice()`: bei Gewicht über der obersten Stufe wird nun die **teuerste** Stufe (`sortByDesc('weight_to')->first()`) genutzt statt der günstigsten. +2. **Zentrale Sperre** beim Hinzufügen/Hochsetzen: Neue Yard-Helfer `getMaxWeight()` (höchstes `weight_to` des Versandlandes) und `exceedsMaxWeight($additionalWeight)`. Würde eine Mengenerhöhung das Maximalgewicht überschreiten, wird das Produkt **nicht** hinzugefügt und ein Hinweis (`msg.cart_max_weight_reached`, DE/EN/FR/ES) angezeigt. Mengenreduktionen sind immer erlaubt. + +Eingebaut in alle interaktiven Warenkörbe: regulärer Bestell-Warenkorb (`User\OrderController::handleUpdateCart`, Helfer `exceedsCartMaxWeight()`), Webshop (`Web\CardController` add/post/`updateCard`), Portal-Nachbestellung (`Portal\OrderController::addToCart`), Abo-Einmal-Artikel (`AboOneTimeService::add/update`) **und** permanente Abo-Artikel (`User\AboController`/`Portal\AboController` `addProduct`/`updateCart`). Für den Abo-Flow berechnet `AboOrderCart::combinedWeight()` das Gesamtgewicht aus regulären Abo- **und** Einmal-Artikeln; `AboOrderCart::exceedsMaxWeight()` prüft gegen das Maximalgewicht des (über `initYard()` gesetzten) Versandlandes. Programmatische Sammel-/Kombi-Bestellungen (Gewicht 0) bleiben unberührt. + +**9.11 Berater-Bestell-Warenkorb: Plus/Minus-Buttons + Inline-Hinweis statt Alert-Popup.** +Der Bestell-Warenkorb (`yard_view_form`) nutzte zuvor ein Zahlen-Input mit Spinner; bei einer Mengenüberschreitung (z. B. Maximalgewicht) erschien ein `alert()`-Popup, **der Zähler lief jedoch weiter hoch**. → Angeglichen an den Abo-Stil: Mengensteuerung jetzt über `−`/`+`-Buttons (`cart-remove-event`/`cart-add-event`, `input-group`/`input-extra`). Die `−`/`+`-Handler in `iq-shopping-cart.js` (`change_cart_qty`) erhöhen die Anzeige **nicht** optimistisch, sondern erst nach Server-Bestätigung per Re-Render. Wird das Update serverseitig abgelehnt, liefert `OrderController::cartUpdateRejected()` den **unveränderten** Warenkorb (`response: true`, `html_card` mit aktueller Menge) plus `error_message` zurück → der Zähler bleibt stehen und der Hinweis erscheint als **Inline-Alert** (`#insert_show_error_message`) über dem Warenkorb (kein Popup). JS-Refresh (`showCartError`) ersetzt das frühere `alert()` durch dieselbe Inline-Meldung. Betrifft Gewichts- **und** Sortiments-Sperre (`cart_max_weight_reached`, `cart_product_not_allowed_for_order_type`). + +**9.12 Kompensationsprodukte bei Versandstufenwechsel sauber synchronisieren.** +Wenn Einmal-Artikel oder reguläre Abo-Änderungen das Gesamtgewicht in eine andere Versandstufe verschieben, kann sich `num_comp` erhöhen oder wieder auf `0` fallen. Die alte Logik in `AboOrderCart::checkNumOfCompProducts()` legte durch eine Off-by-one-Schleife zu viele Comp-Produkte an und entfernte bestehende Comp-Produkte nicht, wenn die neue Versandstufe keine mehr benötigt. Fix: Es werden exakt fehlende Comp-Positionen `1..num_comp` angelegt, überschüssige Positionen konsequent aus DB und Yard entfernt, auch bei `num_comp = 0`. Zusätzlich wird im Sales-Center-Detail beim Laden offener Einmal-Artikel direkt `checkNumOfCompProducts()` ausgeführt (Portal hatte dies bereits), und AJAX-Re-Render respektiert wieder den tatsächlichen `add_only_mode`. + +Nachtrag: Der normale Abo-Update-Endpunkt (`updateCompProduct`, reguläre Mengenänderungen) baut den Yard nun bei offenem Einmal-Fenster ebenfalls wieder **inkl. Einmal-Artikeln** auf, bevor `checkNumOfCompProducts()` läuft. Dadurch verschwindet ein nur durch Einmal-/Zusatzprodukte benötigtes Kompensationsprodukt beim Wechsel des Switches nicht mehr. Der Comp-Bereich wird als leerer Holder immer gerendert und bei Einmal-AJAX-Aktionen mit aktualisiert; Comp-Positionen oberhalb des reinen Abo-Bedarfs zeigen einen Hinweis, dass sie durch zusätzlich hinzugefügte Produkte nötig sind. + +**9.13 Aktive Zustimmung bei dauerhaften Abo-Änderungen (umgesetzt über Modal).** +Dauerhafte Abo-Mengenerhöhungen werden über das zentrale Bestätigungsmodal `_confirm_add_modal.blade.php` abgesichert: Vor der Änderung sieht der User Titel, Hinweistext, Artikel-/Mengenänderung, zusätzliche Kosten, neuen Abo-Gesamtbetrag, Wirksamkeitsdatum und AGB-/Widerrufs-Links. Erst der Klick auf „Zahlungspflichtig aktualisieren" bestätigt die kostenpflichtige Änderung. Ein separates Pending-Datenmodell wird für diesen Stand **nicht** umgesetzt, da die aktive Zustimmung fachlich über das Modal erfolgt. Einmal-Artikel bleiben davon getrennt und haben ihren eigenen Confirm-/Discard-State. + +--- + +## 10. Offene Entscheidung (zu klären, blockiert NICHT Phase 1–7) + +**Automatisiertes Retry** (du erwähntest: „muss noch etwas mehr automatisiert werden"): +Der bestehende Retry ist Admin-manuell (`AboRetryPaymentService`). Eine **automatische** Wiederholung für `status=3`-Abos (geplanter Command, Intervall/Anzahl Versuche, Abbruchkriterien, Benachrichtigungen) ist sinnvoll, aber ein **eigenständiges Arbeitspaket**. + +→ **Empfehlung:** Als separates Folge-Ticket/Phase außerhalb dieses Features umsetzen, um Scope und Risiko klein zu halten. **Entscheidung ausstehend** (in diesem Feature: nur **user-seitiges manuelles** Retry, Phase 4.4). + +#Ja genau das ist ein eigenständiges Arbeitspaket. Erst mal nur den Retry wie in Phase 4.4 +--- + +## 11. Änderungs- / Fortschritts-Log + +| Datum | Phase | Beschreibung | Status | +| ----- | ----- | ------------ | ------ | +| 2026-06-05 | – | Plan erstellt, Rahmenbedingungen geklärt | Plan finalisiert | +| 2026-06-05 | 1 | Datenmodell (`user_abo_one_time_items`, `is_abo_addon`), Model + Factory, Relation, Setting, `isOneTimeWindowOpen()`, 13 Tests grün; Nebenfix `trans_languages`-Migration-Guard | ERLEDIGT | +| 2026-06-05 | 2 | Backend: `addOneTimeItemsToYard`/`buildOneTimeSnapshot`/`getSplitSummary`, `AboOneTimeService`, `oneTime`-Endpoints (User+Portal) + Routen, `AboOneTimeItemRequest` + Übersetzungen; 10 Tests grün (23 gesamt) | ERLEDIGT | +| 2026-06-05 | 3 | Frontend: View-Partials (`_order_onetime`, `_order_onetime_show`, `_order_combined_summary`), `split_mode` in `_order_abo_show`, Onetime-Modal + `oneTimeDatatable` (User+Portal), 2-spaltiges Layout mit sticky Summen-Card (detail/my_abo), JS `iq-abo-onetime.js`, Modal-Branch `abo-add-onetime`, Routen, 10 Übersetzungs-Keys; 4 Render-Tests grün (27 gesamt) | ERLEDIGT | +| 2026-06-05 | Bugfix | Warenkorb-Trennung: Abo-Flow auf eigene Yard-Instanz `'abo'` (9.8), behebt Vermischung mit dem Bestell-Warenkorb über mehrere Fenster | ERLEDIGT | +| 2026-06-05 | Bugfix | Versandkosten-Anzeige bei steuerbefreit in Abo-Übersichten auf Netto angeglichen (9.9) | ERLEDIGT | +| 2026-06-05 | Bugfix | Maximalgewicht-Sperre für alle Warenkörbe + Fallback-Fix (teuerste statt günstigste Stufe) (9.10); `Yard::getMaxWeight()`/`exceedsMaxWeight()`, `AboOrderCart::combinedWeight()`/`exceedsMaxWeight()`, 4 Sprach-Keys; 17 Tests grün | ERLEDIGT | +| 2026-06-05 | Bugfix | Berater-Bestell-Warenkorb: `−`/`+`-Buttons (Abo-Stil) statt Spinner, Inline-Hinweis statt Alert-Popup, Zähler läuft bei Stop nicht mehr hoch (9.11); `cartUpdateRejected()` + JS `change_cart_qty`/`showCartError` | ERLEDIGT | +| 2026-06-05 | Bugfix | Kompensationsprodukte bei Versandstufenwechsel korrigiert (9.12): exakte Anlage statt Off-by-one, Entfernen auch bei `num_comp=0`, CRM-Detail synchronisiert Comp-Produkte nach Einmal-Artikeln, AJAX-Re-Render respektiert `add_only_mode`; Nachtrag: Comp-Switch-Rebuild berücksichtigt Einmal-Artikel, Comp-Hinweis für Zusatzprodukt-bedingte Positionen, Live-Render des Comp-Holders; 37 Tests grün | ERLEDIGT | +| 2026-06-05 | UI/Consent | Rechtssicheres Modal für dauerhafte Abo-Mengenerhöhungen zentralisiert (`_confirm_add_modal`): Artikel-/Mengenänderung, Zusatzkosten, neuer Gesamtbetrag, Lieferdatum, AGB/Widerruf, Button „Zahlungspflichtig aktualisieren" (9.13) | ERLEDIGT | +| 2026-06-05 | UI/Onetime | Confirm-/Discard-State für Einmalprodukte: `confirmed_qty`/`confirmed_at`, Aktionen `confirm`/`discard`, rechtliche Bestätigungsfläche, Erfolgshinweis nach Speichern und Rücksprung auf ungespeichert bei späteren Änderungen | ERLEDIGT | +| 2026-06-05 | UI/Summen | Abo-Split-Liste zeigt neben der Abo-Zwischensumme auch die Endsumme der nächsten Lieferung; kombinierte Summen-Card optisch hervorgehoben; letzte Prüfung: 8 View-Tests / 28 Assertions grün | ERLEDIGT | diff --git a/public/js/iq-abo-onetime.js b/public/js/iq-abo-onetime.js new file mode 100644 index 0000000..0341927 --- /dev/null +++ b/public/js/iq-abo-onetime.js @@ -0,0 +1,297 @@ +var IqAboOneTime = { + card: "#onetime_order_card", + holder: "#insert_show_onetime_order", + abo_holder: "#insert_show_products_order", + summary_card: "#combined_summary_card", + comp_holder: "#holder_html_view_comp_product", + modal: "#modals-load-content", + oTable: null, + url: null, + + btn_modal_add: ".add-onetime-product-basket", + btn_add: ".onetime-add-from-basket", + btn_remove: ".onetime-remove-from-basket", + input_change: ".onetime-input-onchange", + remove_item: ".remove_onetime_from_cart", + confirm_changes: ".onetime-confirm-changes", + discard_changes: ".onetime-discard-changes", + + summary_col: "#combined_summary_col", + sticky_gap: 16, + _stickyTicking: false, + + init: function () { + var _self = this; + _self.url = $(_self.card).data("onetime-route"); + _self.reInitCart(); + _self.initSticky(); + return _self; + }, + + initSticky: function () { + var _self = this; + var card = document.querySelector(_self.summary_card); + var col = document.querySelector(_self.summary_col); + if (!card || !col) { + return; + } + + var onScrollOrResize = function () { + if (_self._stickyTicking) { + return; + } + _self._stickyTicking = true; + window.requestAnimationFrame(function () { + _self.updateSticky(card, col); + _self._stickyTicking = false; + }); + }; + + // Capture-Phase, damit Scroll-Events beliebiger Scroll-Container erfasst werden. + window.addEventListener("scroll", onScrollOrResize, true); + window.addEventListener("resize", onScrollOrResize); + _self.updateSticky(card, col); + }, + + resetSticky: function (card) { + card.style.position = ""; + card.style.top = ""; + card.style.left = ""; + card.style.width = ""; + card.style.zIndex = ""; + }, + + updateSticky: function (card, col) { + var _self = this; + + // Nur im zweispaltigen Layout (lg+) kleben. + if (window.innerWidth < 992) { + _self.resetSticky(card); + return; + } + + var colStyle = window.getComputedStyle(col); + var padLeft = parseFloat(colStyle.paddingLeft) || 0; + var padRight = parseFloat(colStyle.paddingRight) || 0; + var colRect = col.getBoundingClientRect(); + var contentLeft = colRect.left + padLeft; + var contentWidth = col.clientWidth - padLeft - padRight; + var cardHeight = card.offsetHeight; + var gap = _self.sticky_gap; + + if (colRect.top >= gap) { + // Noch nicht über den oberen Rand gescrollt -> normaler Fluss. + _self.resetSticky(card); + return; + } + + // Oben anheften, aber maximal bis zum Ende der Spalte. + var topPos = gap; + var maxTop = colRect.bottom - cardHeight; + if (maxTop < gap) { + topPos = maxTop; + } + + card.style.position = "fixed"; + card.style.top = topPos + "px"; + card.style.left = contentLeft + "px"; + card.style.width = contentWidth + "px"; + card.style.zIndex = "1020"; + }, + + setDatabase: function (oTable) { + this.oTable = oTable; + }, + + reInitModal: function () { + var _self = this; + $(_self.oTable) + .find(_self.btn_modal_add) + .off("click") + .on("click", function () { + _self.addFromModal($(this)); + }); + }, + + reInitCart: function () { + var _self = this; + var $holder = $(_self.holder); + + $holder + .find(_self.btn_add) + .off("click") + .on("click", function () { + _self.changeQty($(this), 1); + }); + $holder + .find(_self.btn_remove) + .off("click") + .on("click", function () { + _self.changeQty($(this), -1); + }); + $holder + .find(_self.input_change) + .off("change") + .on("change", function () { + _self.updateInput($(this)); + }); + $holder + .find(_self.remove_item) + .off("click") + .on("click", function (event) { + event.preventDefault(); + _self.removeItem($(this)); + }); + $holder + .find(_self.confirm_changes) + .off("click") + .on("click", function () { + _self.confirmChanges(); + }); + $holder + .find(_self.discard_changes) + .off("click") + .on("click", function () { + _self.discardChanges(); + }); + }, + + addFromModal: function (_obj) { + var _self = this; + var $modal = $(_self.modal); + $modal.one("hidden.bs.modal", function () { + _self + .performRequest({ + action: "add", + product_id: _obj.data("product-id"), + }) + .done(_self.refreshView); + }); + $modal.modal("hide"); + }, + + changeQty: function (_obj, delta) { + var _self = this; + var itemId = _obj.data("onetime-item-id"); + var input = $(_self.holder).find( + 'input[name="onetime_qty_' + itemId + '"]' + ); + var qty = _self.checkNumber(parseInt(input.val(), 10) + delta); + input.val(qty); + _self + .performRequest({ + action: "update", + one_time_item_id: itemId, + qty: qty, + }) + .done(_self.refreshView); + }, + + updateInput: function (_obj) { + var _self = this; + var qty = _self.checkNumber(parseInt(_obj.val(), 10)); + _obj.val(qty); + _self + .performRequest({ + action: "update", + one_time_item_id: _obj.data("onetime-item-id"), + qty: qty, + }) + .done(_self.refreshView); + }, + + removeItem: function (_obj) { + var _self = this; + _self + .performRequest({ + action: "remove", + one_time_item_id: _obj.data("onetime-item-id"), + }) + .done(_self.refreshView); + }, + + confirmChanges: function () { + var _self = this; + _self + .performRequest({ + action: "confirm", + }) + .done(_self.refreshView); + }, + + discardChanges: function () { + var _self = this; + _self + .performRequest({ + action: "discard", + }) + .done(_self.refreshView); + }, + + refreshView: function (data) { + var _self = IqAboOneTime; + if (data.html_onetime) { + $(_self.holder).html(data.html_onetime); + } + if (data.html_abo) { + $(_self.abo_holder).html(data.html_abo); + } + if (data.html_summary) { + $(_self.summary_card).html(data.html_summary); + } + if (data.html_comp !== undefined) { + $(_self.comp_holder).html(data.html_comp); + } + $(_self.modal).modal("hide"); + _self.reInitCart(); + if (typeof iqModalCart !== "undefined" && iqModalCart.reInitCart) { + iqModalCart.reInitCart($(_self.abo_holder)); + iqModalCart.reInitCart($(_self.comp_holder)); + } + var card = document.querySelector(_self.summary_card); + var col = document.querySelector(_self.summary_col); + if (card && col) { + _self.updateSticky(card, col); + } + }, + + performRequest: function (data) { + var _self = this; + return $.ajax({ + url: _self.url, + data: data, + type: "POST", + dataType: "json", + cache: false, + headers: { + "X-CSRF-TOKEN": $('meta[name="csrf-token"]').attr("content"), + }, + }).fail(function (jqXHR) { + var msg = + jqXHR.responseJSON && jqXHR.responseJSON.message + ? jqXHR.responseJSON.message + : "Error"; + var errorEl = document.getElementById("insert_onetime_error_message"); + if (!errorEl) { + errorEl = document.createElement("div"); + errorEl.id = "insert_onetime_error_message"; + errorEl.className = "alert alert-danger mt-2"; + var holder = document.querySelector("#insert_show_onetime_order"); + if (holder) { + holder.insertBefore(errorEl, holder.firstChild); + } + } + errorEl.textContent = msg; + }); + }, + + checkNumber: function (number) { + if (number < 1 || isNaN(number)) { + return 1; + } + if (number > 100) { + return 100; + } + return number; + }, +}; diff --git a/public/js/iq-modal-cart.js b/public/js/iq-modal-cart.js index 789fb7d..2cd414c 100644 --- a/public/js/iq-modal-cart.js +++ b/public/js/iq-modal-cart.js @@ -45,16 +45,66 @@ var IqModalCart = { } }); }, - showConfirm: function(productName, productPrice, qtyInfo, callback) { + parseCurrency: function(value) { + if (value === undefined || value === null) { + return null; + } + + var normalized = String(value) + .replace(/<[^>]*>/g, "") + .replace(/€/g, "") + .replace(/€/g, "") + .replace(/\s/g, "") + .replace(/\./g, "") + .replace(",", "."); + var amount = parseFloat(normalized); + + return isNaN(amount) ? null : amount; + }, + formatCurrency: function(value) { + if (value === undefined || value === null || isNaN(value)) { + return "-"; + } + + return ( + value.toLocaleString("de-DE", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }) + " €" + ); + }, + showConfirm: function(productName, productPrice, qtyInfo, callback, options) { var _self = this; var $confirmModal = $(_self.confirmModal); - var titleKey = _self.addOnlyMode ? "title-add-only" : "title-normal"; - var warningKey = _self.addOnlyMode ? "warning-add-only" : "warning-normal"; - $("#modal-confirm-add-label").text($confirmModal.data(titleKey)); - $("#confirm-add-warning-text").text($confirmModal.data(warningKey)); + var confirmOptions = options || {}; + var confirmType = confirmOptions.type || "add"; + var additionalCost = _self.parseCurrency(productPrice); + var currentTotal = _self.parseCurrency( + $("#value-amount").text() || $confirmModal.data("current-total"), + ); + var newTotal = + currentTotal !== null && additionalCost !== null + ? currentTotal + additionalCost + : null; + + var title = $confirmModal.data("title-" + confirmType); + var info = $confirmModal.data("info-" + confirmType); + $("#modal-confirm-add-label").text(title || $confirmModal.data("title-add")); + $("#confirm-add-info-text").text(info || $confirmModal.data("info-add")); $("#confirm-add-product-name").text(productName || "-"); - $("#confirm-add-product-price").html(productPrice || "-"); + $("#confirm-add-product-price").html( + additionalCost !== null + ? "+ " + + _self.formatCurrency(additionalCost) + + " " + + ($confirmModal.data("per-delivery") || "") + : productPrice || "-", + ); $("#confirm-add-qty-info").text(qtyInfo || ""); + $("#confirm-add-new-total").text(_self.formatCurrency(newTotal)); + $("#confirm-add-next-billing-date").text( + $confirmModal.data("next-billing-date") || "-", + ); _self.pendingAction = callback; $confirmModal.modal("show"); }, @@ -76,15 +126,21 @@ var IqModalCart = { var productPrice = _obj.data("product-price") || ""; var $modal = $(_self.modal); $modal.one("hidden.bs.modal", function() { - _self.showConfirm(productName, productPrice, "1x", function() { - _self - .performRequest({ - product_id: _obj.data("product-id"), - qty: 1, - action: "addProduct", - }) - .done(_self.refreshView); - }); + _self.showConfirm( + productName, + productPrice, + "1x", + function() { + _self + .performRequest({ + product_id: _obj.data("product-id"), + qty: 1, + action: "addProduct", + }) + .done(_self.refreshView); + }, + { type: "add" }, + ); }); $modal.modal("hide"); }, @@ -117,9 +173,11 @@ var IqModalCart = { _self.remove_from_cart($(this), _obj); }); if (_self.is_for === "me" || _self.is_for === "abo-me") { - $('input[name^="' + _self.comp_products + '"]').on("change", function() { - _self.update_comp_product($(this)); - }); + $('input[name^="' + _self.comp_products + '"]') + .off("change") + .on("change", function() { + _self.update_comp_product($(this)); + }); } }, update_comp_product: function(_obj) { @@ -149,10 +207,16 @@ var IqModalCart = { var productPrice = _obj.data("product-price") || ""; var qtyInfo = currentQty + " \u2192 " + qty; console.log(qtyInfo); - _self.showConfirm(productName, productPrice, qtyInfo, function() { - input.val(qty); - _self.update_cart(_holder, _obj, qty); - }); + _self.showConfirm( + productName, + productPrice, + qtyInfo, + function() { + input.val(qty); + _self.update_cart(_holder, _obj, qty); + }, + { type: "increase" }, + ); }, remove_product: function(_obj, _holder) { var _self = this; @@ -200,12 +264,16 @@ var IqModalCart = { var obj = $(_self.cart_holder); obj.html(data.html_cart); $(_self.insert_show_total_order).html(data.html_total); + if (data.html_summary) { + $("#combined_summary_card").html(data.html_summary); + } $(_self.modal).modal("hide"); - if ($(_self.comp_holder)) { + if (data.html_comp !== undefined) { $(_self.comp_holder).html(data.html_comp); } if ($("#value-amount")) { $("#value-amount").html(data.amount); + $(_self.confirmModal).data("current-total", data.amount); } _self.reInitCart(obj); }, diff --git a/public/js/iq-shopping-cart.js b/public/js/iq-shopping-cart.js index 72310d6..92c2ab9 100755 --- a/public/js/iq-shopping-cart.js +++ b/public/js/iq-shopping-cart.js @@ -12,6 +12,8 @@ var IqShoppingCart = { oTable: null, table_input: '.table-input-event-onchange', cart_input: '.cart-input-event-onchange', + cart_add: '.cart-add-event', + cart_remove: '.cart-remove-event', remove_item: '.remove_item_form_cart', shipping_state: '#change_shipping_state', comp_products: 'switchers-comp-product', @@ -57,6 +59,12 @@ var IqShoppingCart = { $(_self.cart_input).on('change', function(){ _self.update_input_cart($(this)); }); + $(_self.cart_add).off('click').on('click', function(){ + _self.change_cart_qty($(this), 1); + }); + $(_self.cart_remove).off('click').on('click', function(){ + _self.change_cart_qty($(this), -1); + }); $(_self.remove_item).on('click', function(event){ event.preventDefault(); _self.update_cart_database($(this).data('product-id'), 0); @@ -88,6 +96,18 @@ var IqShoppingCart = { _obj.val(qty); _self.update_cart_database(_obj.data('product-id'), qty); }, + /** + * Plus-/Minus-Buttons im Warenkorb: Menge nicht optimistisch erhöhen, sondern + * erst nach Server-Antwort (Re-Render) anzeigen. So bleibt bei einem Stop + * (z. B. Maximalgewicht) der Zähler stehen und es erscheint ein Inline-Hinweis. + */ + change_cart_qty: function (_obj, delta){ + var _self = this; + var productId = _obj.data('product-id'); + var input = $(_self.card_holder).find('.cart-input-event-onchange[data-product-id="'+productId+'"]'); + var qty = _self.checkNumber(parseInt(input.val(), 10) + delta); + _self.update_cart_database(productId, qty); + }, update_comp_product: function (_obj){ var _self = this; _self.performRequest({comp_product_id: _obj.val(), comp_num: _obj.data('comp_num'), count_comp_products: $('input[name="'+_self.count_comp_products+'"]').val(), action: 'updateCompProduct'}) @@ -170,10 +190,24 @@ var IqShoppingCart = { } }, + showCartError: function (message){ + var _self = this; + var errorEl = document.getElementById('insert_show_error_message'); + if (!errorEl) { + errorEl = document.createElement('div'); + errorEl.id = 'insert_show_error_message'; + errorEl.className = 'alert alert-danger mt-2'; + var cartContent = document.getElementById('cartContent'); + if (cartContent) { + cartContent.insertBefore(errorEl, cartContent.firstChild); + } + } + errorEl.textContent = message; + }, refreshItemsAndView: function (data){ var _self = IqShoppingCart; if (data && data.response === false && data.message) { - alert(data.message); + _self.showCartError(data.message); return; } $(_self.card_holder).html(data.html_card); @@ -185,7 +219,7 @@ var IqShoppingCart = { refreshDatabaseAndView: function (data) { var _self = IqShoppingCart; if (data && data.response === false && data.message) { - alert(data.message); + _self.showCartError(data.message); return; } $(_self.card_holder).html(data.html_card); diff --git a/resources/lang/de/abo.php b/resources/lang/de/abo.php index 8ee9e48..4ebce2d 100644 --- a/resources/lang/de/abo.php +++ b/resources/lang/de/abo.php @@ -75,10 +75,21 @@ return [ 'error_add_only_no_remove' => 'Das Entfernen von Produkten ist während der Mindestlaufzeit nicht möglich.', 'confirm_add_title' => 'Produkt hinzufügen bestätigen', 'confirm_add_title_normal' => 'Produkt zum Abo hinzufügen', + 'confirm_increase_title' => 'Abo-Menge erhöhen', 'confirm_add_warning' => 'Während der Mindestlaufzeit können hinzugefügte Produkte nicht wieder entfernt werden. Bitte prüfe Deine Auswahl sorgfältig.', 'confirm_add_warning_normal' => 'Möchtest Du dieses Produkt wirklich zu Deinem Abo hinzufügen?', + 'confirm_add_info' => 'Du bist dabei, diesen Artikel zu Deinem laufenden Abo hinzuzufügen. Dadurch ändert sich Dein regelmäßiger Zahlungsbetrag.', + 'confirm_increase_info' => 'Du bist dabei, die Menge dieses Artikels in Deinem laufenden Abo zu erhöhen. Dadurch ändert sich Dein regelmäßiger Zahlungsbetrag.', + 'confirm_article_details' => 'Artikel-Details', + 'confirm_additional_costs' => 'Zusätzliche Kosten', + 'confirm_new_total' => 'Neuer Abo-Gesamtbetrag', + 'confirm_per_delivery' => 'pro Lieferung', + 'confirm_legal_notice' => 'Die Änderung wird zur nächsten Lieferung am :nextBillingDate wirksam. Es gelten unsere :agb und die :withdrawal.', + 'confirm_terms_link' => 'AGB', + 'confirm_withdrawal_link' => 'Widerrufsbelehrung', + 'confirm_next_delivery_unknown' => 'nächsten Liefertermin', 'confirm_add_cancel' => 'Abbrechen', - 'confirm_add_ok' => 'Ja, hinzufügen', + 'confirm_add_ok' => 'Zahlungspflichtig aktualisieren', 'add_product' => 'Produkt hinzufügen', 'product_prices_career_level_info' => 'Die Produktpreise werden entsprechend Deinem Karriere-Level :user_level_name abzüglich :user_level_margin % Marge angezeigt und brechnet.', 'product_prices_career_level_cpay_info' => 'Die Produktpreise werden als Kunden VK-Preise angezeigt, nach Abschluss der Kundenzahlung erhälst du Deine Provision entsprechend Deinem Karriere-Level :user_level_name Provision :user_level_margin %.', @@ -91,6 +102,28 @@ return [ 'need_basis_product' => 'Sie müssen min. ein Basis-Produkt in Ihrem Abo haben, bitte fügen Sie erst ein neues Basis-Produkt hinzu und entfernen Sie dann das alte Basis-Produkt!', 'abo_item_not_found' => 'Abo-Position nicht gefunden', 'product_not_found' => 'Produkt nicht gefunden', + 'onetime_window_closed' => 'Das Zeitfenster zum Hinzufügen einmaliger Produkte ist aktuell nicht geöffnet.', + 'onetime_product_not_allowed' => 'Dieses Produkt kann nicht einmalig hinzugefügt werden.', + 'onetime_action_required' => 'Es wurde keine Aktion angegeben.', + 'onetime_action_invalid' => 'Ungültige Aktion.', + 'onetime_qty_required' => 'Bitte eine gültige Menge angeben.', + 'onetime_card_hl' => 'Einmalige Produkte zur nächsten Lieferung hinzufügen', + 'onetime_card_info' => 'Füge hier Produkte aus dem regulären Sortiment einmalig zu deiner nächsten Abo-Lieferung hinzu. So sparst du dir zusätzliche Versandkosten und erhältst alles in einem Paket.', + 'onetime_important_hl' => 'Wichtig:', + 'onetime_coverage_hint' => 'Der zusätzliche Betrag wird bei der nächsten Ausführung bequem zusammen mit deinem regulären Abo-Betrag abgebucht.', + 'onetime_reset_hint' => 'Einmalige Produkte werden nur deiner nächsten Lieferung beigelegt und danach automatisch wieder aus dem Abo entfernt.', + 'add_onetime_product' => 'Einmaliges Produkt hinzufügen', + 'onetime_subtotal' => 'Zwischensumme einmalige Produkte', + 'onetime_next_delivery_total' => 'Gesamtbetrag der nächsten Lieferung (Abo + Einmalig)', + 'onetime_legal_notice' => 'Die hinzugefügten Produkte werden einmalig am :nextBillingDate versendet. Es gelten unsere :agb und die :withdrawal.', + 'onetime_discard_changes' => 'Änderungen verwerfen', + 'onetime_confirm_paid' => 'Zahlungspflichtig aktualisieren', + 'onetime_confirm_success' => 'Die einmaligen Produkte wurden erfolgreich für deine nächste Lieferung vorgemerkt.', + 'abo_subtotal' => 'Zwischensumme Abo-Produkte', + 'abo_next_delivery_total' => 'Gesamtbetrag der nächsten Lieferung', + 'onetime_empty' => 'Noch keine einmaligen Produkte hinzugefügt.', + 'onetime_badge' => 'Einmalig', + 'combined_summary_hl' => 'Gesamtsumme der nächsten Lieferung', 'create_abo' => 'Abo erstellen', 'info' => 'Info', 'data' => 'Daten', diff --git a/resources/lang/de/msg.php b/resources/lang/de/msg.php index 94381f3..41a29c6 100644 --- a/resources/lang/de/msg.php +++ b/resources/lang/de/msg.php @@ -37,6 +37,7 @@ return [ 'shopping_user_not_found' => 'Fehler: Es wurde kein ShoppingUser gefunden', 'account_released' => 'Account freigeschaltet', 'cart_product_not_allowed_for_order_type' => 'Der Warenkorb enthält Artikel, die für diese Bestellart nicht vorgesehen sind. Bitte leere den Warenkorb und wähle nur Produkte, die zu dieser Bestellung passen.', + 'cart_max_weight_reached' => 'Das maximale Versandgewicht ist erreicht. Es können keine weiteren Produkte hinzugefügt werden.', ]; /* {{ __('msg.') }} diff --git a/resources/lang/de/order.php b/resources/lang/de/order.php index 67e6a2b..ab0c67a 100644 --- a/resources/lang/de/order.php +++ b/resources/lang/de/order.php @@ -83,6 +83,7 @@ return [ 'ship_to_this_email_info' => 'Der Bestelllink wird Deinem Kunden an folgenden E-Mail-Adresse gesendet:', 'shipping' => 'Versand', 'shipping_compensation_product' => 'Versand Kompensationsprodukt', + 'comp_required_by_additional_products' => 'Dieses Kompensationsprodukt wird durch die zusätzlich hinzugefügten Produkte benötigt.', 'shipping_costs' => 'Versandkosten', 'shopping_cart' => 'Warenkorb', 'shopping_cart_delete' => 'Warenkorb löschen', diff --git a/resources/lang/en/abo.php b/resources/lang/en/abo.php index 69e9067..c354a6b 100644 --- a/resources/lang/en/abo.php +++ b/resources/lang/en/abo.php @@ -66,10 +66,21 @@ return [ 'error_add_only_no_remove' => 'Removing products is not possible during the minimum duration period.', 'confirm_add_title' => 'Confirm adding product', 'confirm_add_title_normal' => 'Add product to subscription', + 'confirm_increase_title' => 'Increase subscription quantity', 'confirm_add_warning' => 'During the minimum duration, added products cannot be removed again. Please check your selection carefully.', 'confirm_add_warning_normal' => 'Do you really want to add this product to your subscription?', + 'confirm_add_info' => 'You are about to add this item to your active subscription. This changes your recurring payment amount.', + 'confirm_increase_info' => 'You are about to increase the quantity of this item in your active subscription. This changes your recurring payment amount.', + 'confirm_article_details' => 'Item details', + 'confirm_additional_costs' => 'Additional costs', + 'confirm_new_total' => 'New subscription total', + 'confirm_per_delivery' => 'per delivery', + 'confirm_legal_notice' => 'The change will take effect with the next delivery on :nextBillingDate. Our :agb and :withdrawal apply.', + 'confirm_terms_link' => 'terms and conditions', + 'confirm_withdrawal_link' => 'right of withdrawal', + 'confirm_next_delivery_unknown' => 'next delivery date', 'confirm_add_cancel' => 'Cancel', - 'confirm_add_ok' => 'Yes, add product', + 'confirm_add_ok' => 'Update with payment obligation', 'add_product' => 'Add product', 'product_prices_career_level_info' => 'Product prices are displayed and calculated according to your career level :user_level_name minus :user_level_margin % margin.', 'product_prices_career_level_cpay_info' => 'The product prices are displayed as customer retail prices. After the customer payment is completed, you will receive your commission according to your career level :user_level_name commission :user_level_margin %.', @@ -82,6 +93,28 @@ return [ 'need_basis_product' => 'You must have at least one base product in your subscription. Please first add a new base product and then remove the old base product!', 'abo_item_not_found' => 'Subscription item not found', 'product_not_found' => 'Product not found', + 'onetime_window_closed' => 'The time window for adding one-time products is currently closed.', + 'onetime_product_not_allowed' => 'This product cannot be added as a one-time item.', + 'onetime_action_required' => 'No action was provided.', + 'onetime_action_invalid' => 'Invalid action.', + 'onetime_qty_required' => 'Please provide a valid quantity.', + 'onetime_card_hl' => 'Add one-time products to your next delivery', + 'onetime_card_info' => 'Add products from the regular assortment once to your next subscription delivery. This saves additional shipping costs and you receive everything in one parcel.', + 'onetime_important_hl' => 'Important:', + 'onetime_coverage_hint' => 'The additional amount will be conveniently charged together with your regular subscription amount on the next execution.', + 'onetime_reset_hint' => 'One-time products are added only to your next delivery and are automatically removed from the subscription afterwards.', + 'add_onetime_product' => 'Add one-time product', + 'onetime_subtotal' => 'Subtotal one-time products', + 'onetime_next_delivery_total' => 'Total amount of next delivery (subscription + one-time)', + 'onetime_legal_notice' => 'The added products will be shipped once on :nextBillingDate. Our :agb and :withdrawal apply.', + 'onetime_discard_changes' => 'Discard changes', + 'onetime_confirm_paid' => 'Update with payment obligation', + 'onetime_confirm_success' => 'The one-time products were successfully reserved for your next delivery.', + 'abo_subtotal' => 'Subtotal subscription products', + 'abo_next_delivery_total' => 'Total amount of next delivery', + 'onetime_empty' => 'No one-time products added yet.', + 'onetime_badge' => 'One-time', + 'combined_summary_hl' => 'Total of next delivery', 'create_abo' => 'Create subscription', 'info' => 'Info', 'data' => 'Data', diff --git a/resources/lang/en/msg.php b/resources/lang/en/msg.php index f737fce..3103e0b 100644 --- a/resources/lang/en/msg.php +++ b/resources/lang/en/msg.php @@ -39,4 +39,5 @@ return [ '' => 'Your shopping cart is empty, please add products first', ], 'cart_product_not_allowed_for_order_type' => 'Your cart contains items that are not allowed for this order type. Please clear the cart and only add products that match this order.', + 'cart_max_weight_reached' => 'The maximum shipping weight has been reached. No further products can be added.', ]; diff --git a/resources/lang/en/order.php b/resources/lang/en/order.php index 04bd052..16fe2d5 100644 --- a/resources/lang/en/order.php +++ b/resources/lang/en/order.php @@ -95,6 +95,7 @@ return [ 'ship_to_this_email_info' => 'The order link will be sent to your customer at the following email address:', 'shipping' => 'shipment', 'shipping_compensation_product' => 'shipping compensation product', + 'comp_required_by_additional_products' => 'This compensation product is required because of the additionally added products.', 'shipping_costs' => 'shipping', 'shopping_cart' => 'shopping cart', 'shopping_cart_delete' => 'clear cart', diff --git a/resources/lang/es/abo.php b/resources/lang/es/abo.php index c886ed5..3b5d27b 100644 --- a/resources/lang/es/abo.php +++ b/resources/lang/es/abo.php @@ -66,10 +66,21 @@ return [ 'error_add_only_no_remove' => 'No es posible eliminar productos durante el periodo de duración mínima.', 'confirm_add_title' => 'Confirmar agregar producto', 'confirm_add_title_normal' => 'Agregar producto a la suscripción', + 'confirm_increase_title' => 'Aumentar cantidad de suscripción', 'confirm_add_warning' => 'Durante la duración mínima, los productos agregados no pueden ser eliminados. Por favor, revise su selección cuidadosamente.', 'confirm_add_warning_normal' => '¿Realmente desea agregar este producto a su suscripción?', + 'confirm_add_info' => 'Está a punto de agregar este artículo a su suscripción activa. Esto modifica el importe de pago recurrente.', + 'confirm_increase_info' => 'Está a punto de aumentar la cantidad de este artículo en su suscripción activa. Esto modifica el importe de pago recurrente.', + 'confirm_article_details' => 'Detalles del artículo', + 'confirm_additional_costs' => 'Costes adicionales', + 'confirm_new_total' => 'Nuevo total de la suscripción', + 'confirm_per_delivery' => 'por entrega', + 'confirm_legal_notice' => 'El cambio será efectivo con la próxima entrega el :nextBillingDate. Se aplican nuestros :agb y el :withdrawal.', + 'confirm_terms_link' => 'términos y condiciones', + 'confirm_withdrawal_link' => 'derecho de desistimiento', + 'confirm_next_delivery_unknown' => 'próxima fecha de entrega', 'confirm_add_cancel' => 'Cancelar', - 'confirm_add_ok' => 'Sí, agregar', + 'confirm_add_ok' => 'Actualizar con obligación de pago', 'add_product' => 'Añadir producto', 'product_prices_career_level_info' => 'Los precios de los productos se muestran y calculan según su nivel de carrera :user_level_name menos :user_level_margin % de margen.', 'product_prices_career_level_cpay_info' => 'Los precios de los productos se muestran como precios minoristas para el cliente. Después de completar el pago del cliente, recibirá su comisión según su nivel de carrera :user_level_name comisión :user_level_margin %.', @@ -82,6 +93,28 @@ return [ 'need_basis_product' => 'Debe tener al menos un producto base en su suscripción. ¡Primero agregue un nuevo producto base y luego elimine el producto base anterior!', 'abo_item_not_found' => 'Artículo de suscripción no encontrado', 'product_not_found' => 'Producto no encontrado', + 'onetime_window_closed' => 'La ventana de tiempo para añadir productos puntuales no está abierta actualmente.', + 'onetime_product_not_allowed' => 'Este producto no se puede añadir como artículo puntual.', + 'onetime_action_required' => 'No se ha indicado ninguna acción.', + 'onetime_action_invalid' => 'Acción no válida.', + 'onetime_qty_required' => 'Indique una cantidad válida.', + 'onetime_card_hl' => 'Añadir productos únicos a su próxima entrega', + 'onetime_card_info' => 'Añada productos del surtido habitual una sola vez a su próxima entrega de suscripción. Así ahorra gastos de envío adicionales y recibe todo en un solo paquete.', + 'onetime_important_hl' => 'Importante:', + 'onetime_coverage_hint' => 'El importe adicional se cargará cómodamente junto con el importe regular de su suscripción en la próxima ejecución.', + 'onetime_reset_hint' => 'Los productos únicos solo se añaden a su próxima entrega y después se eliminan automáticamente de la suscripción.', + 'add_onetime_product' => 'Añadir producto único', + 'onetime_subtotal' => 'Subtotal productos únicos', + 'onetime_next_delivery_total' => 'Importe total de la próxima entrega (suscripción + único)', + 'onetime_legal_notice' => 'Los productos añadidos se enviarán una sola vez el :nextBillingDate. Se aplican nuestros :agb y el :withdrawal.', + 'onetime_discard_changes' => 'Descartar cambios', + 'onetime_confirm_paid' => 'Actualizar con obligación de pago', + 'onetime_confirm_success' => 'Los productos únicos se han reservado correctamente para su próxima entrega.', + 'abo_subtotal' => 'Subtotal productos de suscripción', + 'abo_next_delivery_total' => 'Importe total de la próxima entrega', + 'onetime_empty' => 'Aún no se han añadido productos únicos.', + 'onetime_badge' => 'Único', + 'combined_summary_hl' => 'Total de la próxima entrega', 'create_abo' => 'Crear suscripción', 'info' => 'Info', 'data' => 'Datos', diff --git a/resources/lang/es/msg.php b/resources/lang/es/msg.php index 4a95a30..5ffcfd8 100644 --- a/resources/lang/es/msg.php +++ b/resources/lang/es/msg.php @@ -1,42 +1,42 @@ 'No se pudo validar el ID de IVA, verifique su entrada', - 'VATID_successfully_entered' => 'ID de IVA ingresado exitosamente', - 'abo_deaktivert' => 'opción de suscripción deshabilitada', - 'account_released' => 'Cuenta activada', - 'booked_package_has_been_changed' => 'El paquete reservado ha sido modificado.', - 'cancel_membership_is_requested' => 'Se ha solicitado la terminación de la membresía', - 'compensation_products_cannot_be_0' => 'Error: Los productos de compensación no pueden ser 0.', - 'contact_delete' => 'contacto eliminado', - 'country_account_has_been_changed__cost_has_been_reset' => 'Se cambió el país de facturación y se restableció la lista de verificación de mercancías.', - 'error_checkbox_not_confirm' => 'Error: Casilla de verificación no confirmada', - 'error_occurred_with_order' => 'Se produjo un error al realizar el pedido.', - 'file_deleted' => 'archivo eliminado', - 'file_empty' => '"archivo vacío"', - 'file_not_found' => 'archivo no encontrado', - 'file_uploaded' => 'archivo subido', - 'homeparty_delete' => 'fiesta de tiempo de espera eliminada', - 'homeparty_guest_delete' => 'invitado a la fiesta de tiempo de espera eliminado', - 'link_for_homeparty_not_found' => 'El enlace para la fiesta de tiempo de espera no se encontró o ya no está activo.', - 'no_change_made' => 'no se realizó ningún cambio', - 'no_id_card_deposited_please_upload_first' => 'No se proporcionó ninguna identificación, cárguela primero', - 'no_trade_licence_deposited_please_upload_first' => 'No hay ninguna licencia comercial almacenada; cárguela primero', - 'please_enter_reason_why_you_not_need_trade_licence' => 'Proporcione una razón por la cual no necesita una licencia comercial', - 'please_select_compensation_product' => 'Por favor seleccione un producto de compensación', - 'please_select_count_compensation_products' => 'Por favor seleccione: contar productos de compensación', - 'reverse_charge_procedure_and_VATID_deleted' => 'Se elimina el procedimiento de inversión del cargo y el número de IVA', - 'shipping_cost_cannot_be_0' => 'Error: el costo de envío no puede ser 0', - 'shipping_costs_were_not_calculated_correctly' => 'Error: los costos de envío no se calcularon correctamente', - 'shipping_country_was_not_correctly' => 'Error: El país de envío no se procesó correctamente en el carrito de compras', - 'shipping_country_was_not_found' => 'Error: País de envío no encontrado', - 'shopping_cart_was_not_user_shop' => 'Error: El consultor no tiene tienda, el pedido no puede continuar', - 'shopping_cart_was_shipping_free' => 'Error: El carrito de compras se especificó como envío gratuito', - 'shopping_instance_not_found' => 'Error: No se ha encontrado ninguna ShoppingInstance', - 'shopping_user_not_found' => 'Error: No se ha encontrado ningún ShoppingUser', - 'user_not_found' => 'El consultor no fue encontrado.
La cuenta ha sido desactivada o eliminada.', - 'your_shopping_cart_is_empty_please_add_products_first' => - array ( - '' => 'Su carrito de compras está vacío, agregue productos primero', - ), -); +return [ + 'VATID_could_not_be_validated' => 'No se pudo validar el ID de IVA, verifique su entrada', + 'VATID_successfully_entered' => 'ID de IVA ingresado exitosamente', + 'abo_deaktivert' => 'opción de suscripción deshabilitada', + 'account_released' => 'Cuenta activada', + 'booked_package_has_been_changed' => 'El paquete reservado ha sido modificado.', + 'cancel_membership_is_requested' => 'Se ha solicitado la terminación de la membresía', + 'cart_max_weight_reached' => 'Se ha alcanzado el peso máximo de envío. No se pueden añadir más productos.', + 'compensation_products_cannot_be_0' => 'Error: Los productos de compensación no pueden ser 0.', + 'contact_delete' => 'contacto eliminado', + 'country_account_has_been_changed__cost_has_been_reset' => 'Se cambió el país de facturación y se restableció la lista de verificación de mercancías.', + 'error_checkbox_not_confirm' => 'Error: Casilla de verificación no confirmada', + 'error_occurred_with_order' => 'Se produjo un error al realizar el pedido.', + 'file_deleted' => 'archivo eliminado', + 'file_empty' => '"archivo vacío"', + 'file_not_found' => 'archivo no encontrado', + 'file_uploaded' => 'archivo subido', + 'homeparty_delete' => 'fiesta de tiempo de espera eliminada', + 'homeparty_guest_delete' => 'invitado a la fiesta de tiempo de espera eliminado', + 'link_for_homeparty_not_found' => 'El enlace para la fiesta de tiempo de espera no se encontró o ya no está activo.', + 'no_change_made' => 'no se realizó ningún cambio', + 'no_id_card_deposited_please_upload_first' => 'No se proporcionó ninguna identificación, cárguela primero', + 'no_trade_licence_deposited_please_upload_first' => 'No hay ninguna licencia comercial almacenada; cárguela primero', + 'please_enter_reason_why_you_not_need_trade_licence' => 'Proporcione una razón por la cual no necesita una licencia comercial', + 'please_select_compensation_product' => 'Por favor seleccione un producto de compensación', + 'please_select_count_compensation_products' => 'Por favor seleccione: contar productos de compensación', + 'reverse_charge_procedure_and_VATID_deleted' => 'Se elimina el procedimiento de inversión del cargo y el número de IVA', + 'shipping_cost_cannot_be_0' => 'Error: el costo de envío no puede ser 0', + 'shipping_costs_were_not_calculated_correctly' => 'Error: los costos de envío no se calcularon correctamente', + 'shipping_country_was_not_correctly' => 'Error: El país de envío no se procesó correctamente en el carrito de compras', + 'shipping_country_was_not_found' => 'Error: País de envío no encontrado', + 'shopping_cart_was_not_user_shop' => 'Error: El consultor no tiene tienda, el pedido no puede continuar', + 'shopping_cart_was_shipping_free' => 'Error: El carrito de compras se especificó como envío gratuito', + 'shopping_instance_not_found' => 'Error: No se ha encontrado ninguna ShoppingInstance', + 'shopping_user_not_found' => 'Error: No se ha encontrado ningún ShoppingUser', + 'user_not_found' => 'El consultor no fue encontrado.
La cuenta ha sido desactivada o eliminada.', + 'your_shopping_cart_is_empty_please_add_products_first' => [ + '' => 'Su carrito de compras está vacío, agregue productos primero', + ], +]; diff --git a/resources/lang/es/order.php b/resources/lang/es/order.php index a54c9d2..1ce3d61 100644 --- a/resources/lang/es/order.php +++ b/resources/lang/es/order.php @@ -96,6 +96,7 @@ return [ 'ship_to_this_email_info' => 'El enlace del pedido se enviará a su cliente a la siguiente dirección de correo electrónico', 'shipping' => 'envío', 'shipping_compensation_product' => 'producto de compensación de envío', + 'comp_required_by_additional_products' => 'Este producto de compensación es necesario por los productos añadidos adicionalmente.', 'shipping_costs' => 'envío', 'shopping_cart' => 'carro de la compra', 'shopping_cart_delete' => 'vaciar carrito', diff --git a/resources/lang/fr/abo.php b/resources/lang/fr/abo.php index 15c5c81..53d94c6 100644 --- a/resources/lang/fr/abo.php +++ b/resources/lang/fr/abo.php @@ -75,10 +75,21 @@ return [ 'error_add_only_no_remove' => 'La suppression de produits n’est pas possible pendant la durée minimale.', 'confirm_add_title' => 'Confirmer l’ajout du produit', 'confirm_add_title_normal' => 'Ajouter le produit à l’abonnement', + 'confirm_increase_title' => 'Augmenter la quantité de l’abonnement', 'confirm_add_warning' => 'Pendant la durée minimale, les produits ajoutés ne peuvent pas être retirés. Veuillez vérifier soigneusement votre sélection.', 'confirm_add_warning_normal' => 'Voulez-vous vraiment ajouter ce produit à votre abonnement ?', + 'confirm_add_info' => 'Vous êtes sur le point d’ajouter cet article à votre abonnement en cours. Cela modifie le montant de paiement récurrent.', + 'confirm_increase_info' => 'Vous êtes sur le point d’augmenter la quantité de cet article dans votre abonnement en cours. Cela modifie le montant de paiement récurrent.', + 'confirm_article_details' => 'Détails de l’article', + 'confirm_additional_costs' => 'Coûts supplémentaires', + 'confirm_new_total' => 'Nouveau total de l’abonnement', + 'confirm_per_delivery' => 'par livraison', + 'confirm_legal_notice' => 'La modification prendra effet lors de la prochaine livraison le :nextBillingDate. Nos :agb et le :withdrawal s’appliquent.', + 'confirm_terms_link' => 'conditions générales', + 'confirm_withdrawal_link' => 'droit de rétractation', + 'confirm_next_delivery_unknown' => 'prochaine date de livraison', 'confirm_add_cancel' => 'Annuler', - 'confirm_add_ok' => 'Oui, ajouter', + 'confirm_add_ok' => 'Mettre à jour avec obligation de paiement', 'add_product' => 'Ajouter le produit', 'product_prices_career_level_info' => 'Les prix produits sont affichés et calculés selon votre niveau de carrière :user_level_name moins :user_level_margin % de marge.', 'product_prices_career_level_cpay_info' => 'Les prix produits sont affichés comme prix de vente client ; après finalisation du paiement client, vous recevez votre commission selon votre niveau de carrière :user_level_name, commission :user_level_margin %.', @@ -91,6 +102,28 @@ return [ 'need_basis_product' => 'Vous devez avoir au moins un produit de base dans votre abonnement. Veuillez d’abord ajouter un nouveau produit de base puis supprimer l’ancien !', 'abo_item_not_found' => 'Position d’abonnement introuvable', 'product_not_found' => 'Produit introuvable', + 'onetime_window_closed' => "La période pour ajouter des produits ponctuels n'est pas ouverte actuellement.", + 'onetime_product_not_allowed' => 'Ce produit ne peut pas être ajouté comme article ponctuel.', + 'onetime_action_required' => "Aucune action n'a été indiquée.", + 'onetime_action_invalid' => 'Action invalide.', + 'onetime_qty_required' => 'Veuillez indiquer une quantité valide.', + 'onetime_card_hl' => 'Ajouter des produits ponctuels à votre prochaine livraison', + 'onetime_card_info' => 'Ajoutez une seule fois des produits de l\'assortiment habituel à votre prochaine livraison d\'abonnement. Vous économisez ainsi des frais de port supplémentaires et recevez tout dans un seul colis.', + 'onetime_important_hl' => 'Important :', + 'onetime_coverage_hint' => 'Le montant supplémentaire sera débité facilement avec le montant habituel de votre abonnement lors de la prochaine exécution.', + 'onetime_reset_hint' => 'Les produits ponctuels sont ajoutés uniquement à votre prochaine livraison puis supprimés automatiquement de l\'abonnement.', + 'add_onetime_product' => 'Ajouter un produit ponctuel', + 'onetime_subtotal' => 'Sous-total produits ponctuels', + 'onetime_next_delivery_total' => 'Montant total de la prochaine livraison (abonnement + ponctuel)', + 'onetime_legal_notice' => 'Les produits ajoutés seront expédiés une seule fois le :nextBillingDate. Nos :agb et le :withdrawal s’appliquent.', + 'onetime_discard_changes' => 'Annuler les modifications', + 'onetime_confirm_paid' => 'Mettre à jour avec obligation de paiement', + 'onetime_confirm_success' => 'Les produits ponctuels ont été réservés avec succès pour votre prochaine livraison.', + 'abo_subtotal' => 'Sous-total produits d\'abonnement', + 'abo_next_delivery_total' => 'Montant total de la prochaine livraison', + 'onetime_empty' => 'Aucun produit ponctuel ajouté pour le moment.', + 'onetime_badge' => 'Ponctuel', + 'combined_summary_hl' => 'Total de la prochaine livraison', 'create_abo' => 'Créer un abonnement', 'info' => 'Info', 'data' => 'Données', diff --git a/resources/lang/fr/msg.php b/resources/lang/fr/msg.php index a8c9a75..c4502e5 100644 --- a/resources/lang/fr/msg.php +++ b/resources/lang/fr/msg.php @@ -37,4 +37,5 @@ return [ 'shopping_user_not_found' => 'Erreur : aucun ShoppingUser trouvé', 'account_released' => 'Compte activé', 'cart_product_not_allowed_for_order_type' => 'Le panier contient des articles qui ne sont pas prévus pour ce type de commande. Veuillez vider le panier et sélectionner uniquement les produits adaptés à cette commande.', + 'cart_max_weight_reached' => "Le poids d'expédition maximal est atteint. Aucun autre produit ne peut être ajouté.", ]; diff --git a/resources/lang/fr/order.php b/resources/lang/fr/order.php index 3eacbb2..4eb4b2b 100644 --- a/resources/lang/fr/order.php +++ b/resources/lang/fr/order.php @@ -83,6 +83,7 @@ return [ 'ship_to_this_email_info' => 'Le lien de commande sera envoyé à votre client à l’adresse e-mail suivante :', 'shipping' => 'Livraison', 'shipping_compensation_product' => 'Produit de compensation livraison', + 'comp_required_by_additional_products' => 'Ce produit de compensation est nécessaire en raison des produits ajoutés en supplément.', 'shipping_costs' => 'Frais de livraison', 'shopping_cart' => 'Panier', 'shopping_cart_delete' => 'Supprimer le panier', diff --git a/resources/views/admin/abo/_confirm_add_modal.blade.php b/resources/views/admin/abo/_confirm_add_modal.blade.php new file mode 100644 index 0000000..1423c68 --- /dev/null +++ b/resources/views/admin/abo/_confirm_add_modal.blade.php @@ -0,0 +1,56 @@ + diff --git a/resources/views/admin/abo/_order_abo.blade.php b/resources/views/admin/abo/_order_abo.blade.php index 5876b76..31b743f 100644 --- a/resources/views/admin/abo/_order_abo.blade.php +++ b/resources/views/admin/abo/_order_abo.blade.php @@ -134,8 +134,10 @@ @php $only_show_products = isset($only_show_products) ? $only_show_products : false; $add_only_mode = isset($add_only_mode) ? $add_only_mode : false; + $split_mode = isset($split_mode) ? $split_mode : false; + $summary = isset($summary) ? $summary : null; @endphp - @include('admin.abo._order_abo_show', ['only_show_products' => $only_show_products, 'add_only_mode' => $add_only_mode]) + @include('admin.abo._order_abo_show', ['only_show_products' => $only_show_products, 'add_only_mode' => $add_only_mode, 'split_mode' => $split_mode, 'summary' => $summary]) \ No newline at end of file diff --git a/resources/views/admin/abo/_order_abo_show.blade.php b/resources/views/admin/abo/_order_abo_show.blade.php index 6d56332..bfd4f9e 100644 --- a/resources/views/admin/abo/_order_abo_show.blade.php +++ b/resources/views/admin/abo/_order_abo_show.blade.php @@ -1,3 +1,4 @@ +@php($cartInstance = \App\Services\AboOrderCart::INSTANCE) @if(isset($error_message) && $error_message)
{{ $error_message }}
@endif @@ -65,37 +66,49 @@
+ @if(isset($split_mode) && $split_mode) + + {{ __('abo.abo_subtotal') }}: + {{ formatNumber($summary['abo']['gross'] ?? 0) }} € + + + {{ __('abo.abo_next_delivery_total') }}: + {{ formatNumber($summary['total_with_shipping'] ?? 0) }} € + + @else + @php($taxFree = Yard::instance($cartInstance)->getUserTaxFree()) {{ __('order.subtotal') }}: - {{ Yard::instance('shopping')->subtotal() }} € + {{ Yard::instance($cartInstance)->subtotal() }} € {{ __('Delivery country') }}: - {{ Yard::instance('shopping')->getShippingCountryName() }} + {{ Yard::instance($cartInstance)->getShippingCountryName() }} {{ __('order.shipping_costs') }}: - {{ Yard::instance('shopping')->shipping() }} € + {{ $taxFree ? Yard::instance($cartInstance)->shippingNet() : Yard::instance($cartInstance)->shipping() }} € - @if(Yard::instance('shopping')->getUserTaxFree()) + @if($taxFree) {{ __('order.sum_net') }}: - {{ Yard::instance('shopping')->subtotalWithShipping() }} € + {{ Yard::instance($cartInstance)->subtotalWithShipping() }} € @else {{ __('order.total_without_VAT') }}: - {{ Yard::instance('shopping')->subtotalWithShipping() }} € + {{ Yard::instance($cartInstance)->subtotalWithShipping() }} € {{ __('order.plus_VAT') }}: - {{ Yard::instance('shopping')->taxWithShipping() }} € + {{ Yard::instance($cartInstance)->taxWithShipping() }} € @endif - - {{ __('order.total_sum') }}: - {{ Yard::instance('shopping')->totalWithShipping() }} € - + + {{ __('order.total_sum') }}: + {{ Yard::instance($cartInstance)->totalWithShipping() }} € + + @endif \ No newline at end of file diff --git a/resources/views/admin/abo/_order_combined_summary.blade.php b/resources/views/admin/abo/_order_combined_summary.blade.php new file mode 100644 index 0000000..b737494 --- /dev/null +++ b/resources/views/admin/abo/_order_combined_summary.blade.php @@ -0,0 +1,50 @@ +@php + $cartInstance = \App\Services\AboOrderCart::INSTANCE; + $taxFree = Yard::instance($cartInstance)->getUserTaxFree(); +@endphp +
+
{{ __('abo.combined_summary_hl') }}
+
+
+ + + @if(($summary['one_time']['gross'] ?? 0) > 0) + + + + + @endif + + + + + + + + + + + + + @if($taxFree) + + + + + @else + + + + + + + + + @endif + + + + + +
{{ __('abo.onetime_subtotal') }}:{{ formatNumber($summary['one_time']['gross']) }} €
{{ __('abo.abo_subtotal') }}:{{ formatNumber($summary['abo']['gross'] ?? 0) }} €
{{ __('Delivery country') }}:{{ Yard::instance($cartInstance)->getShippingCountryName() }}
{{ __('order.shipping_costs') }}:{{ $taxFree ? Yard::instance($cartInstance)->shippingNet() : Yard::instance($cartInstance)->shipping() }} €
{{ __('order.sum_net') }}:{{ Yard::instance($cartInstance)->subtotalWithShipping() }} €
{{ __('order.total_without_VAT') }}:{{ Yard::instance($cartInstance)->subtotalWithShipping() }} €
{{ __('order.plus_VAT') }}:{{ Yard::instance($cartInstance)->taxWithShipping() }} €
{{ __('order.total_sum') }}:{{ Yard::instance($cartInstance)->totalWithShipping() }} €
+
diff --git a/resources/views/admin/abo/_order_onetime.blade.php b/resources/views/admin/abo/_order_onetime.blade.php new file mode 100644 index 0000000..4b9dfe7 --- /dev/null +++ b/resources/views/admin/abo/_order_onetime.blade.php @@ -0,0 +1,23 @@ +
+
+ {{ __('abo.onetime_card_hl') }} +
+
{!! __('abo.onetime_card_info') !!}
+
+ {{ __('abo.onetime_important_hl') }} +
    +
  • {{ __('abo.onetime_reset_hint') }}
  • +
  • {{ __('abo.onetime_coverage_hint') }}
  • +
+
+ + + +
+ @include('admin.abo._order_onetime_show', ['user_abo' => $user_abo, 'summary' => $summary]) +
+
diff --git a/resources/views/admin/abo/_order_onetime_show.blade.php b/resources/views/admin/abo/_order_onetime_show.blade.php new file mode 100644 index 0000000..ebdc231 --- /dev/null +++ b/resources/views/admin/abo/_order_onetime_show.blade.php @@ -0,0 +1,105 @@ +@php + $one_time_items = $user_abo->one_time_items()->with('product')->get(); + $hasOneTimeChanges = \App\Services\AboOneTimeService::hasUnconfirmedChanges($user_abo); + $hasConfirmedOneTimeItems = \App\Services\AboOneTimeService::hasConfirmedItems($user_abo); + $oneTimeGross = $summary['one_time']['gross'] ?? 0; + $nextDeliveryTotal = $summary['total_with_shipping'] ?? 0; + $oneTimeConfirmationState = $hasOneTimeChanges ? 'changed' : ($hasConfirmedOneTimeItems ? 'confirmed' : 'empty'); +@endphp +@if(isset($error_message) && $error_message) +
{{ $error_message }}
+@endif +
+ + + + + + + + + + + @forelse($one_time_items as $one_time_item) + + + + + + + @empty + + + + @endforelse + + + + + + + + + + @if($oneTimeGross > 0 || $hasOneTimeChanges || $hasConfirmedOneTimeItems) + + + + + @endif + +
#{{ __('order.article') }}{{ __('tables.quantity') }}{{ __('tables.price') }}
+ @if($one_time_item->product && count($one_time_item->product->images)) + + @endif + + {{ $one_time_item->product?->getLang('name') }} + {{ __('abo.onetime_badge') }} +
+
{{ __('order.content') }}: {{ $one_time_item->product?->contents }}
+
{{ __('order.art_no') }}: {{ $one_time_item->product?->number }}
+
+ +
+
+
+ + + + + + + +
+
+
+
{{ $one_time_item->getFormattedTotalPrice() }} €
+
{{ __('abo.onetime_empty') }}

{{ __('abo.onetime_subtotal') }}:{{ formatNumber($summary['one_time']['gross'] ?? 0) }} €
{{ __('abo.onetime_next_delivery_total') }}:{{ formatNumber($nextDeliveryTotal) }} €
+
+@if($oneTimeGross > 0 || $hasOneTimeChanges || $hasConfirmedOneTimeItems) +
+

+ {!! __('abo.onetime_legal_notice', [ + 'nextBillingDate' => $user_abo->next_date ?: __('abo.confirm_next_delivery_unknown'), + 'agb' => ''.__('abo.confirm_terms_link').'', + 'withdrawal' => ''.__('abo.confirm_withdrawal_link').'', + ]) !!} +

+ @if($hasOneTimeChanges) +
+ + +
+ @elseif($hasConfirmedOneTimeItems) +
+ {{ __('abo.onetime_confirm_success') }} +
+ @endif +
+@endif diff --git a/resources/views/admin/abo/detail.blade.php b/resources/views/admin/abo/detail.blade.php index e08ef66..c6a0182 100644 --- a/resources/views/admin/abo/detail.blade.php +++ b/resources/views/admin/abo/detail.blade.php @@ -51,13 +51,14 @@ 'data-add-only-mode' => '0', ]) !!} + @php($cartInstance = \App\Services\AboOrderCart::INSTANCE)
@include('admin.abo._order_abo', ['add_only_mode' => false])
- @if ($comp_products && Yard::instance('shopping')->getNumComp() > 0) + @if ($comp_products && Yard::instance($cartInstance)->getNumComp() > 0)
- @include('user.order.comp_product') + @include('user.order.comp_product', ['cart_instance' => $cartInstance])
@endif @@ -78,47 +79,7 @@ {{ __('back') }} - + @include('admin.abo._confirm_add_modal') @if ($user_abo->status === 3 && $user_abo->active) +
+ + {{ Form::text('settings[abo-onetime-window-days][val]', \App\Models\Setting::getContentBySlug('abo-onetime-window-days'), array('class'=>'form-control')) }} + {{ Form::hidden('settings[abo-onetime-window-days][type]', 'int') }} + Anzahl Tage vor der Abo-Ausführung, in denen einmalig Produkte aus dem normalen Sortiment hinzugefügt werden können (Standard: 4). +
diff --git a/resources/views/portal/abo/my_abo.blade.php b/resources/views/portal/abo/my_abo.blade.php index ada8a78..a764488 100755 --- a/resources/views/portal/abo/my_abo.blade.php +++ b/resources/views/portal/abo/my_abo.blade.php @@ -2,20 +2,20 @@ @section('content') -

- {{ __('navigation.my_abo') }} - {{ '#'.$user_abo->payone_userid }} -

+

+ {{ __('navigation.my_abo') }} + {{ '#' . $user_abo->payone_userid }} +

-@if(Session::has('alert-error')) -
-
-
    -
  • {{ Session::get('alert-error') }}
  • -
-
-
-@endif + @if (Session::has('alert-error')) +
+
+
    +
  • {{ Session::get('alert-error') }}
  • +
+
+
+ @endif
@include('admin.abo._detail') @@ -28,15 +28,59 @@ @php $addOnlyMode = App\Services\AboHelper::isAddOnlyMode($user_abo, $view); @endphp - {!! Form::open(['action' => route('user_abos_update', [$view, $user_abo->id]), 'class' => 'form-horizontal', 'id'=>'cart-order-form', 'data-add-only-mode' => $addOnlyMode ? '1' : '0']) !!} - -
- @include('admin.abo._order_abo', ['add_only_mode' => $addOnlyMode]) -
+ {!! Form::open([ + 'action' => route('user_abos_update', [$view, $user_abo->id]), + 'class' => 'form-horizontal', + 'id' => 'cart-order-form', + 'data-add-only-mode' => $addOnlyMode ? '1' : '0', + ]) !!} + + @php + $oneTimeWindowOpen = isset($one_time_window_open) ? $one_time_window_open : false; + $summary = isset($summary) ? $summary : null; + $cartInstance = \App\Services\AboOrderCart::INSTANCE; + @endphp + @if ($oneTimeWindowOpen) +
+
+
+ @include('admin.abo._order_onetime', ['summary' => $summary]) +
+
+ @include('admin.abo._order_abo', [ + 'add_only_mode' => $addOnlyMode, + 'split_mode' => true, + 'summary' => $summary, + ]) +
+
+ @if ($comp_products && Yard::instance($cartInstance)->getNumComp() > 0) + @include('user.order.comp_product', [ + 'cart_instance' => $cartInstance, + 'base_comp_count' => $base_comp_count ?? Yard::instance($cartInstance)->getNumComp(), + ]) + @endif +
+
+
+
+ @include('admin.abo._order_combined_summary', ['summary' => $summary]) +
+
+
+ @else +
+ @include('admin.abo._order_abo', ['add_only_mode' => $addOnlyMode]) +
- @if($comp_products && Yard::instance('shopping')->getNumComp() > 0)
- @include('user.order.comp_product') + @if ($comp_products && Yard::instance($cartInstance)->getNumComp() > 0) + @include('user.order.comp_product', [ + 'cart_instance' => $cartInstance, + 'base_comp_count' => $base_comp_count ?? Yard::instance($cartInstance)->getNumComp(), + ]) + @endif
@endif @@ -47,54 +91,18 @@
- {{ __('abo.back') }} + {{ __('abo.back') }} - + @include('admin.abo._confirm_add_modal') @endsection @section('scripts') + + diff --git a/resources/views/user/order/comp_product.blade.php b/resources/views/user/order/comp_product.blade.php index 2416c77..e6a3d79 100644 --- a/resources/views/user/order/comp_product.blade.php +++ b/resources/views/user/order/comp_product.blade.php @@ -1,39 +1,49 @@ - -
+@php($cartInstance = $cart_instance ?? 'shopping') +@php($baseCompCount = $base_comp_count ?? Yard::instance($cartInstance)->getNumComp()) +
- - @for($i = 1; $i <= Yard::instance('shopping')->getNumComp(); $i++) - @if(Yard::instance('shopping')->getNumComp() > 1) -
{{$i}}. {{ __('order.shipping_compensation_product') }}
- @else -
{{ __('order.shipping_compensation_product') }}
- @endif + + @for ($i = 1; $i <= Yard::instance($cartInstance)->getNumComp(); $i++) + @if (Yard::instance($cartInstance)->getNumComp() > 1) +
{{ $i }}. {{ __('order.shipping_compensation_product') }}
+ @else +
{{ __('order.shipping_compensation_product') }}
+ @endif + @if ($i > $baseCompCount) +
+ {{ __('order.comp_required_by_additional_products') }} +
+ @endif
@php($counter = 1) - @php($checked_id = Yard::instance('shopping')->getCompProductBy($i)) - @foreach($comp_products as $comp_product) + @php($checked_id = Yard::instance($cartInstance)->getCompProductBy($i)) + @foreach ($comp_products as $comp_product)
- @if(count($comp_product->images)) - + @if (count($comp_product->images)) + @endif
{{ $comp_product->getLang('name') }}
{{ __('order.art_no') }}: {{ $comp_product->number }}
- @php($counter++) - @endforeach + @php($counter++) + @endforeach
- @endfor + @endfor
-
\ No newline at end of file +
diff --git a/resources/views/user/order/yard_view_form.blade.php b/resources/views/user/order/yard_view_form.blade.php index a78dfc9..528a1b9 100644 --- a/resources/views/user/order/yard_view_form.blade.php +++ b/resources/views/user/order/yard_view_form.blade.php @@ -87,8 +87,44 @@ font-size: 85%; font-weight: 400; } + + .md-btn-extra { + width: calc(1.7rem + 2px); + line-height: 1.5rem; + } + + /* Nicht umbrechen, sonst stapeln sich -/+/Input untereinander (Bootstrap input-group: flex-wrap: wrap). */ + .quantity-select .input-group { + flex-wrap: nowrap; + justify-content: flex-end; + } + + .quantity-select .input-group .md-btn-extra { + flex: 0 0 auto; + width: calc(1.7rem + 2px); + } + + /* Spezifischer als ".quantity-select input.form-control" (min-width: 4em / inline-block) weiter oben. */ + .quantity-select .input-group .form-control.input-extra { + flex: 0 0 44px; + width: 44px; + min-width: 44px; + display: block; + padding: 0.28rem 0.6rem; + font-size: 0.8rem; + font-weight: 600; + min-height: calc(1.8rem + 2px); + height: calc(1.8rem + 2px); + } + + .input-group-min-w { + min-width: 102px; + }
+ @if (isset($error_message) && $error_message) +
{{ $error_message }}
+ @endif
@@ -176,10 +212,27 @@ 1 x @else - +
+
+ + + + + + + +
+
@endif
diff --git a/routes/domains/crm.php b/routes/domains/crm.php index b09b46e..f0cc88e 100644 --- a/routes/domains/crm.php +++ b/routes/domains/crm.php @@ -160,7 +160,9 @@ Route::domain(config('app.pre_url_crm').config('app.domain').config('app.tld_car Route::get('/user/abos/{view}', 'User\AboController@index')->name('user_abos'); Route::get('/user/abos/detail/{view}/{id}', 'User\AboController@detail')->name('user_abos_detail'); Route::post('/user/abos/update/{view}/{id}', 'User\AboController@update')->name('user_abos_update'); + Route::post('/user/abos/onetime/{view}/{id}', 'User\AboController@oneTime')->name('user_abos_onetime'); Route::get('/user/abo/datatable/{id}', 'User\AboController@datatable')->name('user_abo_datatable'); + Route::get('/user/abo/onetime-datatable/{id}', 'User\AboController@oneTimeDatatable')->name('user_abo_onetime_datatable'); // Route to show team subscriptions (Abos) Route::get('/user/abos/team/show', 'User\TeamController@showAbos')->name('user_abos_team_show'); Route::get('/user/abos/team/detail/{id}', 'User\TeamController@detailAbo')->name('user_abos_team_detail'); diff --git a/routes/domains/portal.php b/routes/domains/portal.php index 4387e84..cdcf28e 100644 --- a/routes/domains/portal.php +++ b/routes/domains/portal.php @@ -48,7 +48,9 @@ Route::domain(config('app.pre_url_portal').config('app.domain').config('app.tld_ Route::get('portal/my-subscriptions', [AboController::class, 'myAbo'])->name('portal.my_subscriptions'); Route::match(['get', 'post'], 'portal/my-subscriptions/create/{step}', [AboController::class, 'myAboCreate'])->name('portal.my_subscriptions.create'); Route::post('portal/my-subscriptions/update/{view}/{id}', [AboController::class, 'update'])->name('user_abos_update'); + Route::post('portal/my-subscriptions/onetime/{view}/{id}', [AboController::class, 'oneTime'])->name('user_abos_onetime'); Route::get('portal/my-subscriptions/datatable/{id}', [AboController::class, 'datatable'])->name('user_abo_datatable'); + Route::get('portal/my-subscriptions/onetime-datatable/{id}', [AboController::class, 'oneTimeDatatable'])->name('user_abo_onetime_datatable'); Route::post('portal/modal/load', [AboController::class, 'modalLoad'])->name('modal_load'); // Routen-Aliase für shared Views (admin.abo._detail, admin.abo._executions) Route::get('portal/my-data/edit/{id?}', function () { diff --git a/tests/Feature/AboOneTimeServiceTest.php b/tests/Feature/AboOneTimeServiceTest.php new file mode 100644 index 0000000..1325120 --- /dev/null +++ b/tests/Feature/AboOneTimeServiceTest.php @@ -0,0 +1,180 @@ +service = new AboOneTimeService; + }); + + it('fügt ein erlaubtes Produkt (show_on 2) mit Preis-Snapshot hinzu', function () { + $abo = makeMeAbo(); + $product = makeProduct(['2']); + + $message = $this->service->handleAction($abo, ['action' => 'add', 'product_id' => $product->id]); + + expect($message)->toBeNull(); + $item = UserAboOneTimeItem::where('user_abo_id', $abo->id)->first(); + expect($item)->not->toBeNull() + ->and($item->qty)->toBe(1) + ->and((float) $item->price)->toBeGreaterThan(0.0) + ->and((float) $item->tax_rate)->toBe(19.0) + ->and((float) $item->price_net)->toBeGreaterThan(0.0); + }); + + it('erhöht die Menge, wenn dasselbe Produkt erneut hinzugefügt wird', function () { + $abo = makeMeAbo(); + $product = makeProduct(['2']); + + $this->service->handleAction($abo, ['action' => 'add', 'product_id' => $product->id]); + $this->service->handleAction($abo, ['action' => 'add', 'product_id' => $product->id]); + + $item = UserAboOneTimeItem::where('user_abo_id', $abo->id)->first(); + expect(UserAboOneTimeItem::where('user_abo_id', $abo->id)->count())->toBe(1) + ->and($item->qty)->toBe(2); + }); + + it('lehnt Produkte aus dem Abo-Sortiment (show_on 12) ab', function () { + $abo = makeMeAbo(); + $product = makeProduct(['12']); + + $message = $this->service->handleAction($abo, ['action' => 'add', 'product_id' => $product->id]); + + expect($message)->toBe(__('abo.onetime_product_not_allowed')) + ->and(UserAboOneTimeItem::where('user_abo_id', $abo->id)->count())->toBe(0); + }); + + it('aktualisiert die Menge eines Einmal-Artikels (geclamped)', function () { + $abo = makeMeAbo(); + $product = makeProduct(['2']); + $this->service->handleAction($abo, ['action' => 'add', 'product_id' => $product->id]); + $item = UserAboOneTimeItem::where('user_abo_id', $abo->id)->first(); + + $this->service->handleAction($abo, ['action' => 'update', 'one_time_item_id' => $item->id, 'qty' => 5]); + expect($item->fresh()->qty)->toBe(5); + + $this->service->handleAction($abo, ['action' => 'update', 'one_time_item_id' => $item->id, 'qty' => 999]); + expect($item->fresh()->qty)->toBe(100); + }); + + it('entfernt einen Einmal-Artikel', function () { + $abo = makeMeAbo(); + $product = makeProduct(['2']); + $this->service->handleAction($abo, ['action' => 'add', 'product_id' => $product->id]); + $item = UserAboOneTimeItem::where('user_abo_id', $abo->id)->first(); + + $message = $this->service->handleAction($abo, ['action' => 'remove', 'one_time_item_id' => $item->id]); + + expect($message)->toBeNull() + ->and(UserAboOneTimeItem::where('user_abo_id', $abo->id)->count())->toBe(0); + }); + + it('bestätigt den aktuellen Stand der Einmal-Artikel', function () { + $abo = makeMeAbo(); + $product = makeProduct(['2']); + $this->service->handleAction($abo, ['action' => 'add', 'product_id' => $product->id]); + + $message = $this->service->handleAction($abo, ['action' => 'confirm']); + $item = UserAboOneTimeItem::where('user_abo_id', $abo->id)->first(); + + expect($message)->toBeNull() + ->and($item->confirmed_qty)->toBe(1) + ->and($item->confirmed_at)->not->toBeNull() + ->and($item->status)->toBe(1) + ->and(AboOneTimeService::hasUnconfirmedChanges($abo))->toBeFalse() + ->and(AboOneTimeService::hasConfirmedItems($abo))->toBeTrue(); + }); + + it('verwirft unbestätigte Änderungen und stellt den bestätigten Stand wieder her', function () { + $abo = makeMeAbo(); + $product = makeProduct(['2']); + $this->service->handleAction($abo, ['action' => 'add', 'product_id' => $product->id]); + $this->service->handleAction($abo, ['action' => 'confirm']); + + $item = UserAboOneTimeItem::where('user_abo_id', $abo->id)->first(); + $this->service->handleAction($abo, ['action' => 'update', 'one_time_item_id' => $item->id, 'qty' => 4]); + + expect(AboOneTimeService::hasUnconfirmedChanges($abo))->toBeTrue(); + + $message = $this->service->handleAction($abo, ['action' => 'discard']); + + expect($message)->toBeNull() + ->and($item->fresh()->qty)->toBe(1) + ->and($item->fresh()->status)->toBe(1) + ->and(AboOneTimeService::hasUnconfirmedChanges($abo))->toBeFalse(); + }); + + it('verhindert das Bearbeiten fremder Einmal-Artikel', function () { + $abo = makeMeAbo(); + $foreignAbo = makeMeAbo(); + $product = makeProduct(['2']); + $this->service->handleAction($foreignAbo, ['action' => 'add', 'product_id' => $product->id]); + $foreignItem = UserAboOneTimeItem::where('user_abo_id', $foreignAbo->id)->first(); + + $message = $this->service->handleAction($abo, ['action' => 'remove', 'one_time_item_id' => $foreignItem->id]); + + expect($message)->toBe(__('abo.abo_item_not_found')) + ->and(UserAboOneTimeItem::find($foreignItem->id))->not->toBeNull(); + }); + + it('liefert eine Fehlermeldung bei ungültiger Aktion', function () { + $abo = makeMeAbo(); + + expect($this->service->handleAction($abo, ['action' => 'foo']))->toBe(__('abo.onetime_action_invalid')); + }); +}); + +describe('AboOrderCart::buildOneTimeSnapshot', function () { + beforeEach(fn () => makeShopEnv()); + + it('berechnet einen konsistenten Brutto/Netto/Steuer-Snapshot', function () { + $abo = makeMeAbo(); + $product = makeProduct(['2'], 119, 19); + + $snapshot = AboOrderCart::buildOneTimeSnapshot($product, $abo); + + expect($snapshot['price'])->toBe(119.0) + ->and($snapshot['tax_rate'])->toBe(19.0) + ->and(round($snapshot['price_net'], 2))->toBe(100.0) + ->and(round($snapshot['tax'], 2))->toBe(19.0) + ->and($snapshot['points'])->toBe(7); + }); +}); + +describe('AboOrderCart::getSplitSummary', function () { + it('trennt Abo- und Einmal-Artikel-Summen anhand der abo_addon-Option', function () { + $yard = Yard::instance(AboOrderCart::INSTANCE); + $yard->destroy(); + + $aboRow = $yard->add(1, 'Abo-Produkt', 1, 119.0, false, false, ['weight' => 100]); + Yard::setTax($aboRow->rowId, 19.0); + + $oneTimeRow = $yard->add(2, 'Einmal-Produkt', 2, 59.5, false, false, ['weight' => 100, 'abo_addon' => true]); + Yard::setTax($oneTimeRow->rowId, 19.0); + + $summary = AboOrderCart::getSplitSummary(); + + expect($summary['abo']['gross'])->toBe(119.0) + ->and(round($summary['abo']['net'], 2))->toBe(100.0) + ->and($summary['one_time']['gross'])->toBe(119.0) + ->and($summary['has_one_time'])->toBeTrue(); + + $yard->destroy(); + }); +}); + +describe('AboOneTimeItemRequest', function () { + it('definiert die erwarteten Validierungsregeln', function () { + $rules = (new AboOneTimeItemRequest)->rules(); + + expect($rules)->toHaveKeys(['action', 'product_id', 'one_time_item_id', 'qty']) + ->and($rules['action'])->toContain('required'); + }); +}); diff --git a/tests/Feature/AboOneTimeViewTest.php b/tests/Feature/AboOneTimeViewTest.php new file mode 100644 index 0000000..bdcd4ff --- /dev/null +++ b/tests/Feature/AboOneTimeViewTest.php @@ -0,0 +1,201 @@ + makeShopEnv()); + + it('rendert die Einmal-Produktliste mit Position und Zwischensumme', function () { + $abo = makeMeAbo(); + $product = makeProduct(['2'], 119, 19); + + UserAboOneTimeItem::create([ + 'user_abo_id' => $abo->id, + 'product_id' => $product->id, + 'qty' => 2, + 'price' => 119.0, + 'price_net' => 100.0, + 'tax_rate' => 19.0, + 'tax' => 19.0, + 'status' => 1, + ]); + + $summary = ['one_time' => ['gross' => 238.0, 'net' => 200.0, 'tax' => 38.0]]; + + $html = view('admin.abo._order_onetime_show', [ + 'user_abo' => $abo->fresh(), + 'summary' => $summary, + ])->render(); + + expect($html)->toContain($product->getLang('name')) + ->and($html)->toContain(__('abo.onetime_subtotal')) + ->and($html)->toContain('238,00'); + }); + + it('zeigt bei unbestätigten Einmal-Artikeln die Bestätigungsbuttons', function () { + $abo = makeMeAbo(); + $product = makeProduct(['2'], 119, 19); + + UserAboOneTimeItem::create([ + 'user_abo_id' => $abo->id, + 'product_id' => $product->id, + 'qty' => 1, + 'price' => 119.0, + 'price_net' => 100.0, + 'tax_rate' => 19.0, + 'tax' => 19.0, + 'status' => 0, + ]); + + $html = view('admin.abo._order_onetime_show', [ + 'user_abo' => $abo->fresh(), + 'summary' => ['one_time' => ['gross' => 119.0], 'total_with_shipping' => 219.0], + ])->render(); + + expect($html)->toContain(__('abo.onetime_next_delivery_total')) + ->and($html)->toContain(__('abo.onetime_legal_notice', [ + 'nextBillingDate' => $abo->next_date, + 'agb' => ''.__('abo.confirm_terms_link').'', + 'withdrawal' => ''.__('abo.confirm_withdrawal_link').'', + ])) + ->and($html)->toContain(__('abo.onetime_discard_changes')) + ->and($html)->toContain(__('abo.onetime_confirm_paid')); + }); + + it('zeigt bei bestätigten Einmal-Artikeln den Erfolgshinweis', function () { + $abo = makeMeAbo(); + $product = makeProduct(['2'], 119, 19); + + UserAboOneTimeItem::create([ + 'user_abo_id' => $abo->id, + 'product_id' => $product->id, + 'qty' => 1, + 'confirmed_qty' => 1, + 'confirmed_at' => now(), + 'price' => 119.0, + 'price_net' => 100.0, + 'tax_rate' => 19.0, + 'tax' => 19.0, + 'status' => 1, + ]); + + $html = view('admin.abo._order_onetime_show', [ + 'user_abo' => $abo->fresh(), + 'summary' => ['one_time' => ['gross' => 119.0], 'total_with_shipping' => 219.0], + ])->render(); + + expect($html)->toContain(__('abo.onetime_confirm_success')) + ->and($html)->toContain('data-confirmation-state="confirmed"') + ->and($html)->not->toContain(__('abo.onetime_confirm_paid')); + }); + + it('zeigt nach einer Änderung an bestätigten Einmal-Artikeln wieder die Bestätigungsbuttons', function () { + $abo = makeMeAbo(); + $product = makeProduct(['2'], 119, 19); + + UserAboOneTimeItem::create([ + 'user_abo_id' => $abo->id, + 'product_id' => $product->id, + 'qty' => 3, + 'confirmed_qty' => 1, + 'confirmed_at' => now(), + 'price' => 119.0, + 'price_net' => 100.0, + 'tax_rate' => 19.0, + 'tax' => 19.0, + 'status' => 0, + ]); + + $html = view('admin.abo._order_onetime_show', [ + 'user_abo' => $abo->fresh(), + 'summary' => ['one_time' => ['gross' => 357.0], 'total_with_shipping' => 457.0], + ])->render(); + + expect($html)->toContain('data-confirmation-state="changed"') + ->and($html)->toContain(__('abo.onetime_discard_changes')) + ->and($html)->toContain(__('abo.onetime_confirm_paid')) + ->and($html)->not->toContain(__('abo.onetime_confirm_success')); + }); + + it('zeigt den Leer-Hinweis, wenn keine Einmal-Produkte vorhanden sind', function () { + $abo = makeMeAbo(); + + $html = view('admin.abo._order_onetime_show', [ + 'user_abo' => $abo, + 'summary' => ['one_time' => ['gross' => 0]], + ])->render(); + + expect($html)->toContain(__('abo.onetime_empty')); + }); + + it('rendert die kombinierte Gesamtsumme mit getrennten Zwischensummen', function () { + $yard = Yard::instance(AboOrderCart::INSTANCE); + $yard->destroy(); + + $aboRow = $yard->add(1, 'Abo-Produkt', 1, 119.0, false, false, ['weight' => 100]); + Yard::setTax($aboRow->rowId, 19.0); + $oneTimeRow = $yard->add(2, 'Einmal-Produkt', 1, 59.5, false, false, ['weight' => 100, 'abo_addon' => true]); + Yard::setTax($oneTimeRow->rowId, 19.0); + + $summary = AboOrderCart::getSplitSummary(); + + $html = view('admin.abo._order_combined_summary', ['summary' => $summary])->render(); + + expect($html)->toContain(__('abo.combined_summary_hl')) + ->and($html)->toContain(__('abo.onetime_subtotal')) + ->and($html)->toContain(__('abo.abo_subtotal')) + ->and($html)->toContain(__('order.total_sum')); + + $yard->destroy(); + }); + + it('rendert die Abo-Liste im Split-Modus mit Abo-Zwischensumme und Endsumme', function () { + $abo = makeMeAbo(); + + $yard = Yard::instance(AboOrderCart::INSTANCE); + $yard->destroy(); + $aboRow = $yard->add(1, 'Abo-Produkt', 1, 119.0, false, false, ['weight' => 100]); + Yard::setTax($aboRow->rowId, 19.0); + + $summary = AboOrderCart::getSplitSummary(); + + $html = view('admin.abo._order_abo_show', [ + 'user_abo' => $abo, + 'split_mode' => true, + 'summary' => $summary, + 'only_show_products' => false, + 'add_only_mode' => false, + ])->render(); + + expect($html)->toContain(__('abo.abo_subtotal')) + ->and($html)->toContain(__('abo.abo_next_delivery_total')) + ->and($html)->not->toContain(__('order.shipping_costs')) + ->and($html)->not->toContain(__('order.total_sum')); + + $yard->destroy(); + }); + + it('rendert den Bestätigungsdialog für kostenpflichtige Abo-Erhöhungen', function () { + $abo = makeMeAbo(); + $abo->amount = 1760; + $abo->save(); + + $html = view('admin.abo._confirm_add_modal', [ + 'user_abo' => $abo, + ])->render(); + + expect($html)->toContain(__('abo.confirm_increase_title')) + ->and($html)->toContain(__('abo.confirm_increase_info')) + ->and($html)->toContain(__('abo.confirm_new_total')) + ->and($html)->toContain(__('abo.confirm_legal_notice', [ + 'nextBillingDate' => '', + 'agb' => ''.__('abo.confirm_terms_link').'', + 'withdrawal' => ''.__('abo.confirm_withdrawal_link').'', + ])) + ->and($html)->toContain(__('abo.confirm_add_ok')); + }); +}); diff --git a/tests/Feature/AboOneTimeWindowTest.php b/tests/Feature/AboOneTimeWindowTest.php new file mode 100644 index 0000000..11517dc --- /dev/null +++ b/tests/Feature/AboOneTimeWindowTest.php @@ -0,0 +1,120 @@ +toBe(AboHelper::DEFAULT_ONETIME_WINDOW_DAYS); + }); + + it('liefert den konfigurierten Wert aus den Settings', function () { + Setting::setContentBySlug('abo-onetime-window-days', 6, 'int'); + + expect(AboHelper::getOneTimeWindowDays())->toBe(6); + }); + + it('fällt bei 0 oder ungültigem Wert auf den Standard zurück', function () { + Setting::setContentBySlug('abo-onetime-window-days', 0, 'int'); + + expect(AboHelper::getOneTimeWindowDays())->toBe(AboHelper::DEFAULT_ONETIME_WINDOW_DAYS); + }); +}); + +describe('isOneTimeWindowOpen', function () { + beforeEach(function () { + Carbon::setTestNow(Carbon::parse('2026-06-05 10:00:00', 'Europe/Berlin')); + }); + + it('ist offen, wenn die Ausführung innerhalb des Fensters liegt', function () { + $abo = new UserAbo; + $abo->next_date = '2026-06-09'; // 4 Tage entfernt, Default-Fenster = 4 + + expect(AboHelper::isOneTimeWindowOpen($abo))->toBeTrue(); + }); + + it('ist offen am Tag der Ausführung', function () { + $abo = new UserAbo; + $abo->next_date = '2026-06-05'; + + expect(AboHelper::isOneTimeWindowOpen($abo))->toBeTrue(); + }); + + it('ist geschlossen, wenn die Ausführung weiter als das Fenster entfernt ist', function () { + $abo = new UserAbo; + $abo->next_date = '2026-06-10'; // 5 Tage entfernt, Fenster = 4 + + expect(AboHelper::isOneTimeWindowOpen($abo))->toBeFalse(); + }); + + it('berücksichtigt ein konfiguriertes größeres Fenster', function () { + Setting::setContentBySlug('abo-onetime-window-days', 7, 'int'); + + $abo = new UserAbo; + $abo->next_date = '2026-06-10'; // 5 Tage entfernt, Fenster = 7 + + expect(AboHelper::isOneTimeWindowOpen($abo))->toBeTrue(); + }); + + it('ist geschlossen, wenn das Ausführungsdatum in der Vergangenheit liegt', function () { + $abo = new UserAbo; + $abo->next_date = '2026-06-04'; + + expect(AboHelper::isOneTimeWindowOpen($abo))->toBeFalse(); + }); + + it('ist geschlossen, wenn kein next_date gesetzt ist', function () { + $abo = new UserAbo; + $abo->next_date = null; + + expect(AboHelper::isOneTimeWindowOpen($abo))->toBeFalse(); + }); +}); + +describe('UserAboOneTimeItem Model', function () { + it('definiert die Beziehung one_time_items auf UserAbo', function () { + $relation = (new UserAbo)->one_time_items(); + + expect($relation)->toBeInstanceOf(Illuminate\Database\Eloquent\Relations\HasMany::class) + ->and($relation->getRelated())->toBeInstanceOf(UserAboOneTimeItem::class); + }); + + it('castet die Snapshot-Felder korrekt', function () { + $item = new UserAboOneTimeItem([ + 'qty' => '3', + 'price' => '12.50', + 'tax_rate' => '19.00', + ]); + + expect($item->qty)->toBe(3) + ->and($item->price)->toBe(12.5) + ->and($item->tax_rate)->toBe(19.0); + }); +}); + +describe('ShoppingOrderItem is_abo_addon', function () { + it('grenzt Abo- und Einmal-Positionen über Scopes ab', function () { + $aboSql = ShoppingOrderItem::query()->aboItems()->toSql(); + $addonSql = ShoppingOrderItem::query()->addonItems()->toSql(); + + expect($aboSql)->toContain('is_abo_addon') + ->and($addonSql)->toContain('is_abo_addon'); + }); + + it('castet is_abo_addon als bool', function () { + $item = new ShoppingOrderItem(['is_abo_addon' => 1]); + + expect($item->is_abo_addon)->toBeTrue(); + }); +}); diff --git a/tests/Feature/CartMaxWeightTest.php b/tests/Feature/CartMaxWeightTest.php new file mode 100644 index 0000000..3976491 --- /dev/null +++ b/tests/Feature/CartMaxWeightTest.php @@ -0,0 +1,232 @@ + 'DE', 'phone' => '49', 'en' => 'Germany', 'de' => 'Deutschland', + 'es' => 'Alemania', 'fr' => 'Allemagne', 'it' => 'Germania', 'ru' => 'Германия', + ]); + $shipping = Shipping::create(['name' => 'Standard', 'active' => true]); + + ShippingPrice::create([ + 'shipping_id' => $shipping->id, 'price' => 4.90, 'price_comp' => 0, 'num_comp' => 0, + 'tax_rate' => 19, 'weight_from' => 1, 'weight_to' => 1000, + ]); + ShippingPrice::create([ + 'shipping_id' => $shipping->id, 'price' => 9.90, 'price_comp' => 0, 'num_comp' => 0, + 'tax_rate' => 19, 'weight_from' => 1001, 'weight_to' => 2000, + ]); + + return ShippingCountry::create(['shipping_id' => $shipping->id, 'country_id' => $country->id]); +} + +function makeShopEnvWithCompWeightTiers(): ShippingCountry +{ + $country = Country::create([ + 'code' => 'DE', 'phone' => '49', 'en' => 'Germany', 'de' => 'Deutschland', + 'es' => 'Alemania', 'fr' => 'Allemagne', 'it' => 'Germania', 'ru' => 'Германия', + ]); + $shipping = Shipping::create(['name' => 'Standard', 'active' => true]); + + ShippingPrice::create([ + 'shipping_id' => $shipping->id, 'price' => 4.90, 'price_comp' => 4.90, 'num_comp' => 0, + 'tax_rate' => 19, 'weight_from' => 1, 'weight_to' => 1000, + ]); + ShippingPrice::create([ + 'shipping_id' => $shipping->id, 'price' => 9.90, 'price_comp' => 9.90, 'num_comp' => 2, + 'tax_rate' => 19, 'weight_from' => 1001, 'weight_to' => 2000, + ]); + + return ShippingCountry::create(['shipping_id' => $shipping->id, 'country_id' => $country->id]); +} + +describe('Yard Maximalgewicht', function () { + beforeEach(function () { + $this->shippingCountry = makeShopEnvWithWeightTiers(); + }); + + it('liefert das höchste weight_to als Maximalgewicht', function () { + $yard = Yard::instance('test-maxweight'); + $yard->destroy(); + $yard->setShippingCountryWithPrice($this->shippingCountry->id); + + expect($yard->getMaxWeight())->toBe(2000); + + $yard->destroy(); + }); + + it('erkennt das Überschreiten des Maximalgewichts', function () { + $yard = Yard::instance('test-maxweight'); + $yard->destroy(); + $yard->setShippingCountryWithPrice($this->shippingCountry->id); + + $yard->add(1, 'A', 1, 10.0, false, false, ['weight' => 1500]); + + expect($yard->exceedsMaxWeight(0))->toBeFalse() + ->and($yard->exceedsMaxWeight(500))->toBeFalse() + ->and($yard->exceedsMaxWeight(501))->toBeTrue(); + + $yard->destroy(); + }); + + it('nutzt bei Überschreitung die teuerste Stufe statt der günstigsten', function () { + $yard = Yard::instance('test-maxweight'); + $yard->destroy(); + $yard->setShippingCountryWithPrice($this->shippingCountry->id); + + // Gewicht 3000 g liegt über der höchsten Stufe (2000 g) + $yard->add(1, 'A', 1, 10.0, false, false, ['weight' => 3000]); + $yard->reCalculateShippingPrice(); + + expect((float) $yard->getShippingPrice())->toBe(9.90); + + $yard->destroy(); + }); +}); + +describe('AboOrderCart Maximalgewicht', function () { + beforeEach(function () { + makeShopEnvWithWeightTiers(); + $this->service = new AboOneTimeService; + }); + + it('berechnet das kombinierte Gewicht aus Einmal-Artikeln', function () { + $abo = makeMeAbo(); + $product = makeProduct(['2']); // weight 500 + + $this->service->handleAction($abo, ['action' => 'add', 'product_id' => $product->id]); + + expect(AboOrderCart::combinedWeight($abo->fresh()))->toBe(500); + }); + + it('stoppt das Hinzufügen, wenn das Maximalgewicht überschritten würde', function () { + $abo = makeMeAbo(); + $product = makeProduct(['2']); // weight 500, max 2000 => max 4 Stück + + for ($i = 0; $i < 4; $i++) { + $message = $this->service->handleAction($abo, ['action' => 'add', 'product_id' => $product->id]); + expect($message)->toBeNull(); + } + + // 5. Stück => 2500 g > 2000 g + $message = $this->service->handleAction($abo, ['action' => 'add', 'product_id' => $product->id]); + + expect($message)->toBe(__('msg.cart_max_weight_reached')) + ->and(UserAboOneTimeItem::where('user_abo_id', $abo->id)->first()->qty)->toBe(4); + }); + + it('stoppt das Hochsetzen der Menge bei Überschreitung, erlaubt aber gültige Mengen', function () { + $abo = makeMeAbo(); + $product = makeProduct(['2']); // weight 500 + $this->service->handleAction($abo, ['action' => 'add', 'product_id' => $product->id]); + $item = UserAboOneTimeItem::where('user_abo_id', $abo->id)->first(); + + // qty 4 => 2000 g (ok) + $okMessage = $this->service->handleAction($abo, ['action' => 'update', 'one_time_item_id' => $item->id, 'qty' => 4]); + expect($okMessage)->toBeNull() + ->and($item->fresh()->qty)->toBe(4); + + // qty 5 => 2500 g (zu schwer) + $failMessage = $this->service->handleAction($abo, ['action' => 'update', 'one_time_item_id' => $item->id, 'qty' => 5]); + expect($failMessage)->toBe(__('msg.cart_max_weight_reached')) + ->and($item->fresh()->qty)->toBe(4); + }); + + it('erlaubt das Reduzieren der Menge immer', function () { + $abo = makeMeAbo(); + $product = makeProduct(['2']); + $this->service->handleAction($abo, ['action' => 'add', 'product_id' => $product->id]); + $item = UserAboOneTimeItem::where('user_abo_id', $abo->id)->first(); + $this->service->handleAction($abo, ['action' => 'update', 'one_time_item_id' => $item->id, 'qty' => 4]); + + $message = $this->service->handleAction($abo, ['action' => 'update', 'one_time_item_id' => $item->id, 'qty' => 2]); + + expect($message)->toBeNull() + ->and($item->fresh()->qty)->toBe(2); + }); +}); + +describe('AboOrderCart Kompensationsprodukte', function () { + beforeEach(function () { + $this->shippingCountry = makeShopEnvWithCompWeightTiers(); + Setting::setContentBySlug('is_comp_me_abo', true, 'bool'); + }); + + it('erhöht die Kompensationsprodukte, wenn die höhere Versandstufe mehr verlangt', function () { + $abo = makeMeAbo(); + $compProduct = makeProduct(['12']); + DB::table('products')->where('id', $compProduct->id)->update(['shipping_addon' => true, 'pos' => 100]); + + $yard = Yard::instance(AboOrderCart::INSTANCE); + $yard->destroy(); + $yard->setShippingCountryWithPrice($this->shippingCountry->id, 'abo-me'); + $yard->add(1, 'Abo-Produkt', 1, 10.0, false, false, ['weight' => 1500]); + $yard->reCalculateShippingPrice(); + + AboOrderCart::checkNumOfCompProducts($abo); + + expect(UserAboItem::where('user_abo_id', $abo->id)->where('comp', '>', 0)->count())->toBe(2) + ->and(UserAboItem::where('user_abo_id', $abo->id)->where('comp', '>', 0)->pluck('comp')->all())->toBe([1, 2]); + + $yard->destroy(); + }); + + it('entfernt Kompensationsprodukte wieder, wenn keine mehr benötigt werden', function () { + $abo = makeMeAbo(); + $compProduct = makeProduct(['12']); + DB::table('products')->where('id', $compProduct->id)->update(['shipping_addon' => true, 'pos' => 100]); + + UserAboItem::create(['user_abo_id' => $abo->id, 'product_id' => $compProduct->id, 'comp' => 1, 'qty' => 1, 'status' => 1]); + UserAboItem::create(['user_abo_id' => $abo->id, 'product_id' => $compProduct->id, 'comp' => 2, 'qty' => 1, 'status' => 1]); + + $yard = Yard::instance(AboOrderCart::INSTANCE); + $yard->destroy(); + $yard->setShippingCountryWithPrice($this->shippingCountry->id, 'abo-me'); + $yard->add(1, 'Abo-Produkt', 1, 10.0, false, false, ['weight' => 500]); + $yard->reCalculateShippingPrice(); + + AboOrderCart::checkNumOfCompProducts($abo); + + expect(UserAboItem::where('user_abo_id', $abo->id)->where('comp', '>', 0)->count())->toBe(0); + + $yard->destroy(); + }); + + it('zeigt einen Hinweis für Kompensationsprodukte aus Zusatzprodukten', function () { + $compProduct = makeProduct(['12']); + + $yard = Yard::instance(AboOrderCart::INSTANCE); + $yard->destroy(); + $yard->setShippingCountryWithPrice($this->shippingCountry->id, 'abo-me'); + $yard->add(1, 'Abo-Produkt', 1, 10.0, false, false, ['weight' => 1500]); + $yard->reCalculateShippingPrice(); + + $html = view('user.order.comp_product', [ + 'comp_products' => collect([$compProduct]), + 'cart_instance' => AboOrderCart::INSTANCE, + 'base_comp_count' => 0, + ])->render(); + + expect($html)->toContain(__('order.comp_required_by_additional_products')); + + $yard->destroy(); + }); +}); diff --git a/tests/Pest.php b/tests/Pest.php index f7b4296..29b4225 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,7 +1,72 @@ in('Feature/Incentive'); uses(Tests\TestCase::class)->in('Feature/Sys'); uses(Tests\TestCase::class)->in('Unit/Incentive'); uses(Tests\TestCase::class)->in('Unit/Dhl'); uses(Tests\TestCase::class)->in('Feature/PaymentDashboard'); + +if (! function_exists('makeShopEnv')) { + function makeShopEnv(): void + { + $country = Country::create([ + 'code' => 'DE', 'phone' => '49', 'en' => 'Germany', 'de' => 'Deutschland', + 'es' => 'Alemania', 'fr' => 'Allemagne', 'it' => 'Germania', 'ru' => 'Германия', + ]); + $shipping = Shipping::create(['name' => 'Standard', 'active' => true]); + ShippingCountry::create(['shipping_id' => $shipping->id, 'country_id' => $country->id]); + } +} + +if (! function_exists('makeMeAbo')) { + function makeMeAbo(): UserAbo + { + $consultant = User::forceCreate([ + 'email' => 'c-'.uniqid('', true).'@example.com', + 'password' => bcrypt('secret'), + 'lang' => 'de', + ]); + + return UserAbo::create([ + 'user_id' => $consultant->id, + 'shopping_user_id' => 1, + 'is_for' => 'me', + 'email' => $consultant->email, + 'payone_userid' => 900100, + 'clearingtype' => 'cc', + 'active' => true, + 'status' => 2, + 'abo_interval' => 5, + 'next_date' => now()->addDays(3), + ]); + } +} + +if (! function_exists('makeProduct')) { + function makeProduct(array $showOn, float $price = 119, float $tax = 19): Product + { + $now = now(); + $id = (int) DB::table('products')->insertGetId([ + 'name' => 'P-'.uniqid('', true), + 'title' => 'Produkt', + 'active' => true, + 'price' => $price, + 'tax' => $tax, + 'weight' => 500, + 'points' => 7, + 'show_on' => json_encode($showOn), + 'created_at' => $now, + 'updated_at' => $now, + ]); + + return Product::findOrFail($id); + } +}