Abo Einmalprodukte und Bestätigung abschließen
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
2bdc9ada3c
commit
2269ce031f
57 changed files with 3647 additions and 371 deletions
|
|
@ -163,13 +163,13 @@ class UserMakeOrder
|
||||||
|
|
||||||
// WICHTIG: Yard komplett leeren vor jedem Abo, um sicherzustellen, dass keine Produkte
|
// WICHTIG: Yard komplett leeren vor jedem Abo, um sicherzustellen, dass keine Produkte
|
||||||
// aus vorherigen Abos im Cart bleiben
|
// aus vorherigen Abos im Cart bleiben
|
||||||
Yard::instance('shopping')->destroy();
|
Yard::instance(AboOrderCart::INSTANCE)->destroy();
|
||||||
|
|
||||||
// initYard akzeptiert nur einen Parameter (user_abo)
|
// initYard akzeptiert nur einen Parameter (user_abo)
|
||||||
AboOrderCart::initYard($this->userAbo);
|
AboOrderCart::initYard($this->userAbo);
|
||||||
|
|
||||||
// Nochmalige Sicherheitsprüfung: Yard sollte leer sein
|
// Nochmalige Sicherheitsprüfung: Yard sollte leer sein
|
||||||
$yardBefore = Yard::instance('shopping');
|
$yardBefore = Yard::instance(AboOrderCart::INSTANCE);
|
||||||
$itemsBefore = $yardBefore->content();
|
$itemsBefore = $yardBefore->content();
|
||||||
if ($itemsBefore->count() > 0) {
|
if ($itemsBefore->count() > 0) {
|
||||||
Log::warning('UserMakeOrder: Yard war nicht leer nach initYard für Abo ID: '.$this->userAbo->id.', Items: '.$itemsBefore->count());
|
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
|
// hier wird die Bestellung erstellt inkl aktueller Preise
|
||||||
AboOrderCart::makeOrderYard($this->userAbo);
|
AboOrderCart::makeOrderYard($this->userAbo);
|
||||||
|
|
||||||
$yard = Yard::instance('shopping');
|
$yard = Yard::instance(AboOrderCart::INSTANCE);
|
||||||
|
|
||||||
// Debug: Logge welche Produkte im Cart sind
|
// Debug: Logge welche Produkte im Cart sind
|
||||||
$items = $yard->content();
|
$items = $yard->content();
|
||||||
|
|
|
||||||
|
|
@ -170,6 +170,10 @@ class ModalController extends Controller
|
||||||
$user_abo = UserAbo::find($data['id']);
|
$user_abo = UserAbo::find($data['id']);
|
||||||
$ret = view('user.abo.modal_abo_show_products', compact('data', 'user_abo'))->render();
|
$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') {
|
if ($data['action'] === 'create-dhl-shipment') {
|
||||||
$this->authorizeDhlShipmentModal();
|
$this->authorizeDhlShipmentModal();
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,17 @@
|
||||||
namespace App\Http\Controllers\Portal;
|
namespace App\Http\Controllers\Portal;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Abo\AboOneTimeItemRequest;
|
||||||
use App\Models\Product;
|
use App\Models\Product;
|
||||||
use App\Models\ShoppingInstance;
|
use App\Models\ShoppingInstance;
|
||||||
use App\Models\ShoppingUser;
|
use App\Models\ShoppingUser;
|
||||||
use App\Models\UserAbo;
|
use App\Models\UserAbo;
|
||||||
use App\Models\UserAboItem;
|
use App\Models\UserAboItem;
|
||||||
|
use App\Models\UserAboOneTimeItem;
|
||||||
use App\Repositories\AboRepository;
|
use App\Repositories\AboRepository;
|
||||||
use App\Services\AboHelper;
|
use App\Services\AboHelper;
|
||||||
use App\Services\AboItemHistoryService;
|
use App\Services\AboItemHistoryService;
|
||||||
|
use App\Services\AboOneTimeService;
|
||||||
use App\Services\AboOrderCart;
|
use App\Services\AboOrderCart;
|
||||||
use App\Services\Shop;
|
use App\Services\Shop;
|
||||||
use App\Services\UserService;
|
use App\Services\UserService;
|
||||||
|
|
@ -68,6 +71,14 @@ class AboController extends Controller
|
||||||
AboOrderCart::initYard($user_abo);
|
AboOrderCart::initYard($user_abo);
|
||||||
$customer_detail = AboOrderCart::getCustomerDetail();
|
$customer_detail = AboOrderCart::getCustomerDetail();
|
||||||
AboOrderCart::makeOrderYard($user_abo);
|
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', [
|
return view('portal.abo.my_abo', [
|
||||||
'user_abo' => $user_abo,
|
'user_abo' => $user_abo,
|
||||||
|
|
@ -75,6 +86,9 @@ class AboController extends Controller
|
||||||
'view' => $view,
|
'view' => $view,
|
||||||
'comp_products' => [],
|
'comp_products' => [],
|
||||||
'isAdmin' => false,
|
'isAdmin' => false,
|
||||||
|
'one_time_window_open' => $oneTimeWindowOpen,
|
||||||
|
'summary' => $summary,
|
||||||
|
'base_comp_count' => $baseCompCount,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -98,11 +112,15 @@ class AboController extends Controller
|
||||||
|
|
||||||
if (Request::ajax()) {
|
if (Request::ajax()) {
|
||||||
$message = false;
|
$message = false;
|
||||||
|
// Yard für Gewichts-/Versandprüfung initialisieren (Versandland setzen)
|
||||||
|
AboOrderCart::initYard($user_abo);
|
||||||
|
|
||||||
// addProduct
|
// addProduct
|
||||||
if ($data['action'] === 'addProduct') {
|
if ($data['action'] === 'addProduct') {
|
||||||
if ($product = Product::find($data['product_id'])) {
|
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;
|
$qtyBefore = $UserAboItem->qty;
|
||||||
$UserAboItem->qty = $UserAboItem->qty + 1;
|
$UserAboItem->qty = $UserAboItem->qty + 1;
|
||||||
$UserAboItem->save();
|
$UserAboItem->save();
|
||||||
|
|
@ -132,9 +150,14 @@ class AboController extends Controller
|
||||||
if ($isAddOnlyMode && $qty < $UserAboItem->qty) {
|
if ($isAddOnlyMode && $qty < $UserAboItem->qty) {
|
||||||
$qty = $UserAboItem->qty;
|
$qty = $UserAboItem->qty;
|
||||||
}
|
}
|
||||||
$UserAboItem->qty = $qty;
|
$additionalWeight = (int) $product->weight * ($qty - $qtyBefore);
|
||||||
$UserAboItem->save();
|
if ($additionalWeight > 0 && AboOrderCart::exceedsMaxWeight($user_abo, $additionalWeight)) {
|
||||||
AboItemHistoryService::logQtyChanged($user_abo, $UserAboItem, $qtyBefore, $qty, $view);
|
$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::initYard($user_abo);
|
||||||
AboOrderCart::makeOrderYard($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);
|
AboOrderCart::checkNumOfCompProducts($user_abo);
|
||||||
|
|
||||||
|
$summary = AboOrderCart::getSplitSummary();
|
||||||
$error_message = $message ? $message : false;
|
$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_cart = view('admin.abo._order_abo_show', [
|
||||||
$html_comp = view('user.order.comp_product', $data)->render();
|
'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();
|
$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)
|
return \DataTables::eloquent($query)
|
||||||
->addColumn('add_card', function (Product $product) {
|
->addColumn('add_card', function (Product $product) {
|
||||||
$tax_free = Yard::instance('shopping')->getUserTaxFree();
|
$tax_free = Yard::instance(AboOrderCart::INSTANCE)->getUserTaxFree();
|
||||||
$price = $product->getFormattedPriceWith($tax_free, false, Yard::instance('shopping')->getUserCountry());
|
$price = $product->getFormattedPriceWith($tax_free, false, Yard::instance(AboOrderCart::INSTANCE)->getUserCountry());
|
||||||
|
|
||||||
return '<button type="button" class="btn btn-sm btn-md-extra btn-secondary add-product-basket" data-product-id="'.$product->id.'" data-product-name="'.e($product->getLang('name')).'" data-product-price="'.$price.' €">
|
return '<button type="button" class="btn btn-sm btn-md-extra btn-secondary add-product-basket" data-product-id="'.$product->id.'" data-product-name="'.e($product->getLang('name')).'" data-product-price="'.$price.' €">
|
||||||
<strong>€ '.$price.'</strong> +<span class="ion ion-md-cart"></span>
|
<strong>€ '.$price.'</strong> +<span class="ion ion-md-cart"></span>
|
||||||
|
|
@ -244,10 +284,10 @@ class AboController extends Controller
|
||||||
return '<span class="no-line-break">'.$product->getFormattedPoints().'</span>';
|
return '<span class="no-line-break">'.$product->getFormattedPoints().'</span>';
|
||||||
})
|
})
|
||||||
->addColumn('price_net', function (Product $product) {
|
->addColumn('price_net', function (Product $product) {
|
||||||
return '<span class="no-line-break">'.$product->getFormattedPriceWith(true, false, Yard::instance('shopping')->getUserCountry()).' €</span>'.'<span class="no-line-break">'.$product->getFormattedPriceCurrencyWith(true, true, Yard::instance('shopping')->getUserCountry()).'</span>';
|
return '<span class="no-line-break">'.$product->getFormattedPriceWith(true, false, Yard::instance(AboOrderCart::INSTANCE)->getUserCountry()).' €</span>'.'<span class="no-line-break">'.$product->getFormattedPriceCurrencyWith(true, true, Yard::instance(AboOrderCart::INSTANCE)->getUserCountry()).'</span>';
|
||||||
})
|
})
|
||||||
->addColumn('price_gross', function (Product $product) {
|
->addColumn('price_gross', function (Product $product) {
|
||||||
return '<span class="no-line-break">'.$product->getFormattedPriceWith(false, false, Yard::instance('shopping')->getUserCountry()).' €</span>'.'<span class="no-line-break">'.$product->getFormattedPriceCurrencyWith(true, true, Yard::instance('shopping')->getUserCountry()).'</span>';
|
return '<span class="no-line-break">'.$product->getFormattedPriceWith(false, false, Yard::instance(AboOrderCart::INSTANCE)->getUserCountry()).' €</span>'.'<span class="no-line-break">'.$product->getFormattedPriceCurrencyWith(true, true, Yard::instance(AboOrderCart::INSTANCE)->getUserCountry()).'</span>';
|
||||||
})
|
})
|
||||||
->addColumn('action', function (Product $product) {
|
->addColumn('action', function (Product $product) {
|
||||||
return '<button class="btn btn-default btn-sm icon-btn md-btn-flat product-tooltip" title="details" data-modal="modal-lg"
|
return '<button class="btn btn-default btn-sm icon-btn md-btn-flat product-tooltip" title="details" data-modal="modal-lg"
|
||||||
|
|
@ -282,6 +322,11 @@ class AboController extends Controller
|
||||||
$this->checkPortalPermission($user_abo);
|
$this->checkPortalPermission($user_abo);
|
||||||
$ret = view('user.abo.modal_abo_show_products', compact('data', 'user_abo'))->render();
|
$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']);
|
||||||
|
$this->checkPortalPermission($user_abo);
|
||||||
|
$ret = view('user.abo.modal_abo_onetime_products', compact('data', 'user_abo'))->render();
|
||||||
|
}
|
||||||
if ($data['action'] === 'abo_update_settings') {
|
if ($data['action'] === 'abo_update_settings') {
|
||||||
$user_abo = UserAbo::find($data['id']);
|
$user_abo = UserAbo::find($data['id']);
|
||||||
$this->checkPortalPermission($user_abo);
|
$this->checkPortalPermission($user_abo);
|
||||||
|
|
@ -301,6 +346,127 @@ class AboController extends Controller
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function oneTime(AboOneTimeItemRequest $request, $view, $id)
|
||||||
|
{
|
||||||
|
$user_abo = UserAbo::findOrFail($id);
|
||||||
|
$this->checkPortalPermission($user_abo);
|
||||||
|
$isAddOnlyMode = AboHelper::isAddOnlyMode($user_abo, $view);
|
||||||
|
|
||||||
|
if (! AboHelper::isOneTimeWindowOpen($user_abo)) {
|
||||||
|
return response()->json([
|
||||||
|
'response' => false,
|
||||||
|
'message' => __('abo.onetime_window_closed'),
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
AboOrderCart::initYard($user_abo);
|
||||||
|
$message = (new 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();
|
||||||
|
|
||||||
|
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' => [],
|
||||||
|
'cart_instance' => AboOrderCart::INSTANCE,
|
||||||
|
'base_comp_count' => $baseCompCount,
|
||||||
|
])->render(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function oneTimeDatatable($user_abo_id)
|
||||||
|
{
|
||||||
|
$user_abo = UserAbo::findOrFail($user_abo_id);
|
||||||
|
$this->checkPortalPermission($user_abo);
|
||||||
|
|
||||||
|
AboOrderCart::initYard($user_abo);
|
||||||
|
|
||||||
|
$query = Product::select('products.*')
|
||||||
|
->where('active', true)
|
||||||
|
->whereJsonContains('show_on', '3')
|
||||||
|
->orderBy('name', 'asc');
|
||||||
|
|
||||||
|
return \DataTables::eloquent($query)
|
||||||
|
->addColumn('add_card', function (Product $product) {
|
||||||
|
$tax_free = Yard::instance(AboOrderCart::INSTANCE)->getUserTaxFree();
|
||||||
|
$price = $product->getFormattedPriceWith($tax_free, false, Yard::instance(AboOrderCart::INSTANCE)->getUserCountry());
|
||||||
|
|
||||||
|
return '<button type="button" class="btn btn-sm btn-md-extra btn-warning add-onetime-product-basket" data-product-id="'.$product->id.'" data-product-name="'.e($product->getLang('name')).'" data-product-price="'.$price.' €">
|
||||||
|
<strong>€ '.$price.'</strong> +<span class="ion ion-md-cart"></span>
|
||||||
|
</button>';
|
||||||
|
})
|
||||||
|
->addColumn('picture', function (Product $product) {
|
||||||
|
if (count($product->images)) {
|
||||||
|
return '<img class="img-fluid img-extra" alt="" src="'.route('product_image', [$product->images->first()->slug]).'">';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
})
|
||||||
|
->addColumn('name', function (Product $product) {
|
||||||
|
return '<strong>'.$product->getLang('name').'</strong>';
|
||||||
|
})
|
||||||
|
->addColumn('points', function (Product $product) {
|
||||||
|
return '<span class="no-line-break">'.$product->getFormattedPoints().'</span>';
|
||||||
|
})
|
||||||
|
->addColumn('price_net', function (Product $product) {
|
||||||
|
return '<span class="no-line-break">'.$product->getFormattedPriceWith(true, false, Yard::instance(AboOrderCart::INSTANCE)->getUserCountry()).' €</span>';
|
||||||
|
})
|
||||||
|
->addColumn('price_gross', function (Product $product) {
|
||||||
|
return '<span class="no-line-break">'.$product->getFormattedPriceWith(false, false, Yard::instance(AboOrderCart::INSTANCE)->getUserCountry()).' €</span>';
|
||||||
|
})
|
||||||
|
->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<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
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)
|
public function checkNeedBasisProduct($user_abo, $product, $order_item_id)
|
||||||
{
|
{
|
||||||
if (AboHelper::getAboShowOn($product) !== 'base') {
|
if (AboHelper::getAboShowOn($product) !== 'base') {
|
||||||
|
|
|
||||||
|
|
@ -238,6 +238,13 @@ class OrderController extends Controller
|
||||||
$image = $product->images->first()?->slug ?? '';
|
$image = $product->images->first()?->slug ?? '';
|
||||||
$yard = Yard::instance($this->instance);
|
$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(
|
$cartItem = $yard->add(
|
||||||
$product->id,
|
$product->id,
|
||||||
$product->getLang('name'),
|
$product->getLang('name'),
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@
|
||||||
namespace App\Http\Controllers\User;
|
namespace App\Http\Controllers\User;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Abo\AboOneTimeItemRequest;
|
||||||
use App\Models\Product;
|
use App\Models\Product;
|
||||||
use App\Models\UserAbo;
|
use App\Models\UserAbo;
|
||||||
use App\Models\UserAboItem;
|
use App\Models\UserAboItem;
|
||||||
|
use App\Models\UserAboOneTimeItem;
|
||||||
use App\Repositories\AboRepository;
|
use App\Repositories\AboRepository;
|
||||||
use App\Services\AboHelper;
|
use App\Services\AboHelper;
|
||||||
use App\Services\AboItemHistoryService;
|
use App\Services\AboItemHistoryService;
|
||||||
|
|
@ -85,18 +87,30 @@ class AboController extends Controller
|
||||||
// holt die aktuellen UserAccount Daten oder die Userdaten des Abo
|
// holt die aktuellen UserAccount Daten oder die Userdaten des Abo
|
||||||
$customer_detail = AboOrderCart::getCustomerDetail();
|
$customer_detail = AboOrderCart::getCustomerDetail();
|
||||||
AboOrderCart::makeOrderYard($user_abo);
|
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 = [];
|
$comp_products = [];
|
||||||
if ($user_abo->is_for === 'me') {
|
if ($user_abo->is_for === 'me') {
|
||||||
$comp_products = Shop::getCompProducts('abo-me');
|
$comp_products = Shop::getCompProducts('abo-me');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$summary = AboOrderCart::getSplitSummary();
|
||||||
|
|
||||||
$data = [
|
$data = [
|
||||||
'user_abo' => $user_abo,
|
'user_abo' => $user_abo,
|
||||||
'isAdmin' => false,
|
'isAdmin' => false,
|
||||||
'customer_detail' => $customer_detail,
|
'customer_detail' => $customer_detail,
|
||||||
'view' => $view,
|
'view' => $view,
|
||||||
'comp_products' => $comp_products,
|
'comp_products' => $comp_products,
|
||||||
|
'one_time_window_open' => $oneTimeWindowOpen,
|
||||||
|
'summary' => $summary,
|
||||||
|
'base_comp_count' => $baseCompCount,
|
||||||
];
|
];
|
||||||
|
|
||||||
return view('user.abo.detail', $data);
|
return view('user.abo.detail', $data);
|
||||||
|
|
@ -121,10 +135,14 @@ class AboController extends Controller
|
||||||
|
|
||||||
if (Request::ajax()) {
|
if (Request::ajax()) {
|
||||||
$message = false;
|
$message = false;
|
||||||
|
// Yard für Gewichts-/Versandprüfung initialisieren (Versandland setzen)
|
||||||
|
AboOrderCart::initYard($user_abo);
|
||||||
// addProduct
|
// addProduct
|
||||||
if ($data['action'] === 'addProduct') {
|
if ($data['action'] === 'addProduct') {
|
||||||
if ($product = Product::find($data['product_id'])) {
|
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;
|
$qtyBefore = $UserAboItem->qty;
|
||||||
$UserAboItem->qty = $UserAboItem->qty + 1;
|
$UserAboItem->qty = $UserAboItem->qty + 1;
|
||||||
$UserAboItem->save();
|
$UserAboItem->save();
|
||||||
|
|
@ -155,9 +173,14 @@ class AboController extends Controller
|
||||||
if ($isAddOnlyMode && $qty < $UserAboItem->qty) {
|
if ($isAddOnlyMode && $qty < $UserAboItem->qty) {
|
||||||
$qty = $UserAboItem->qty;
|
$qty = $UserAboItem->qty;
|
||||||
}
|
}
|
||||||
$UserAboItem->qty = $qty;
|
$additionalWeight = (int) $product->weight * ($qty - $qtyBefore);
|
||||||
$UserAboItem->save();
|
if ($additionalWeight > 0 && AboOrderCart::exceedsMaxWeight($user_abo, $additionalWeight)) {
|
||||||
AboItemHistoryService::logQtyChanged($user_abo, $UserAboItem, $qtyBefore, $qty, $editView);
|
$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::initYard($user_abo);
|
||||||
AboOrderCart::makeOrderYard($user_abo); // reCalculateShippingPrice
|
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
|
AboOrderCart::checkNumOfCompProducts($user_abo); // after reCalculateShippingPrice check it and remove or add comp product
|
||||||
|
|
||||||
if ($user_abo->is_for === 'me') {
|
if ($user_abo->is_for === 'me') {
|
||||||
$data['comp_products'] = Shop::getCompProducts('abo-me');
|
$data['comp_products'] = Shop::getCompProducts('abo-me');
|
||||||
}
|
}
|
||||||
|
$summary = AboOrderCart::getSplitSummary();
|
||||||
$error_message = $message ? $message : false;
|
$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_cart = view('admin.abo._order_abo_show', [
|
||||||
$html_comp = view('user.order.comp_product', $data)->render();
|
'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();
|
$amount = $user_abo->getFormattedAmount();
|
||||||
|
|
||||||
// $html_total = view("user.homeparty.show_total_order", ['homeparty' => $homeparty])->render();
|
// $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<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
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)
|
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
|
// 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) {
|
->addColumn('add_card', function (Product $product) use ($user_abo) {
|
||||||
$ufactor = $user_abo->is_for === 'me' ? true : false;
|
$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 '<button type="button" class="btn btn-sm btn-md-extra btn-secondary add-product-basket" data-product-id="'.$product->id.'" data-product-name="'.e($product->getLang('name')).'" data-product-price="'.$price.' €">
|
return '<button type="button" class="btn btn-sm btn-md-extra btn-secondary add-product-basket" data-product-id="'.$product->id.'" data-product-name="'.e($product->getLang('name')).'" data-product-price="'.$price.' €">
|
||||||
<strong>€ '.$price.'</strong> +<span class="ion ion-md-cart"></span>
|
<strong>€ '.$price.'</strong> +<span class="ion ion-md-cart"></span>
|
||||||
|
|
@ -302,12 +414,12 @@ class AboController extends Controller
|
||||||
->addColumn('price_net', function (Product $product) use ($user_abo) {
|
->addColumn('price_net', function (Product $product) use ($user_abo) {
|
||||||
$ufactor = $user_abo->is_for === 'me' ? true : false;
|
$ufactor = $user_abo->is_for === 'me' ? true : false;
|
||||||
|
|
||||||
return '<span class="no-line-break">'.$product->getFormattedPriceWith(true, $ufactor, Yard::instance('shopping')->getUserCountry()).' €</span>'.'<span class="no-line-break">'.$product->getFormattedPriceCurrencyWith(true, true, Yard::instance('shopping')->getUserCountry()).'</span>';
|
return '<span class="no-line-break">'.$product->getFormattedPriceWith(true, $ufactor, Yard::instance(AboOrderCart::INSTANCE)->getUserCountry()).' €</span>'.'<span class="no-line-break">'.$product->getFormattedPriceCurrencyWith(true, true, Yard::instance(AboOrderCart::INSTANCE)->getUserCountry()).'</span>';
|
||||||
})
|
})
|
||||||
->addColumn('price_gross', function (Product $product) use ($user_abo) {
|
->addColumn('price_gross', function (Product $product) use ($user_abo) {
|
||||||
$ufactor = $user_abo->is_for === 'me' ? true : false;
|
$ufactor = $user_abo->is_for === 'me' ? true : false;
|
||||||
|
|
||||||
return '<span class="no-line-break">'.$product->getFormattedPriceWith(false, $ufactor, Yard::instance('shopping')->getUserCountry()).' €</span>'.'<span class="no-line-break">'.$product->getFormattedPriceCurrencyWith(true, true, Yard::instance('shopping')->getUserCountry()).'</span>';
|
return '<span class="no-line-break">'.$product->getFormattedPriceWith(false, $ufactor, Yard::instance(AboOrderCart::INSTANCE)->getUserCountry()).' €</span>'.'<span class="no-line-break">'.$product->getFormattedPriceCurrencyWith(true, true, Yard::instance(AboOrderCart::INSTANCE)->getUserCountry()).'</span>';
|
||||||
})
|
})
|
||||||
->addColumn('action', function (Product $product) {
|
->addColumn('action', function (Product $product) {
|
||||||
return '<button class="btn btn-default btn-sm icon-btn md-btn-flat product-tooltip" title="details" data-modal="modal-lg"
|
return '<button class="btn btn-default btn-sm icon-btn md-btn-flat product-tooltip" title="details" data-modal="modal-lg"
|
||||||
|
|
@ -332,6 +444,67 @@ class AboController extends Controller
|
||||||
->make(true);
|
->make(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function oneTimeDatatable($user_abo_id)
|
||||||
|
{
|
||||||
|
$user_abo = UserAbo::findOrFail($user_abo_id);
|
||||||
|
|
||||||
|
AboOrderCart::initYard($user_abo);
|
||||||
|
|
||||||
|
$show_on_ids = $user_abo->is_for === 'me' ? ['2'] : ['3'];
|
||||||
|
$query = Product::select('products.*')
|
||||||
|
->where('active', true)
|
||||||
|
->where(function ($q) use ($show_on_ids) {
|
||||||
|
foreach ($show_on_ids as $id) {
|
||||||
|
$q->orWhereJsonContains('show_on', $id);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->orderBy('name', 'asc');
|
||||||
|
|
||||||
|
return \DataTables::eloquent($query)
|
||||||
|
->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(AboOrderCart::INSTANCE)->getUserTaxFree();
|
||||||
|
|
||||||
|
$price = $product->getFormattedPriceWith($tax_free, $ufactor, Yard::instance(AboOrderCart::INSTANCE)->getUserCountry());
|
||||||
|
|
||||||
|
return '<button type="button" class="btn btn-sm btn-md-extra btn-warning add-onetime-product-basket" data-product-id="'.$product->id.'" data-product-name="'.e($product->getLang('name')).'" data-product-price="'.$price.' €">
|
||||||
|
<strong>€ '.$price.'</strong> +<span class="ion ion-md-cart"></span>
|
||||||
|
</button>';
|
||||||
|
})
|
||||||
|
->addColumn('picture', function (Product $product) {
|
||||||
|
if (count($product->images)) {
|
||||||
|
return '<img class="img-fluid img-extra" alt="" src="'.route('product_image', [$product->images->first()->slug]).'">';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
})
|
||||||
|
->addColumn('name', function (Product $product) {
|
||||||
|
return '<strong>'.$product->getLang('name').'</strong>';
|
||||||
|
})
|
||||||
|
->addColumn('points', function (Product $product) {
|
||||||
|
return '<span class="no-line-break">'.$product->getFormattedPoints().'</span>';
|
||||||
|
})
|
||||||
|
->addColumn('price_net', function (Product $product) use ($user_abo) {
|
||||||
|
$ufactor = $user_abo->is_for === 'me' ? true : false;
|
||||||
|
|
||||||
|
return '<span class="no-line-break">'.$product->getFormattedPriceWith(true, $ufactor, Yard::instance(AboOrderCart::INSTANCE)->getUserCountry()).' €</span>';
|
||||||
|
})
|
||||||
|
->addColumn('price_gross', function (Product $product) use ($user_abo) {
|
||||||
|
$ufactor = $user_abo->is_for === 'me' ? true : false;
|
||||||
|
|
||||||
|
return '<span class="no-line-break">'.$product->getFormattedPriceWith(false, $ufactor, Yard::instance(AboOrderCart::INSTANCE)->getUserCountry()).' €</span>';
|
||||||
|
})
|
||||||
|
->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)
|
private function checkPermissions($view, $user_abo)
|
||||||
{
|
{
|
||||||
\Log::info('checkPermissions', ['view' => $view, 'user_abo' => $user_abo]);
|
\Log::info('checkPermissions', ['view' => $view, 'user_abo' => $user_abo]);
|
||||||
|
|
|
||||||
|
|
@ -773,10 +773,11 @@ class OrderController extends Controller
|
||||||
$isAbo = str_contains($is_for, 'abo');
|
$isAbo = str_contains($is_for, 'abo');
|
||||||
$qty = isset($data['qty']) ? (int) $data['qty'] : 0;
|
$qty = isset($data['qty']) ? (int) $data['qty'] : 0;
|
||||||
if ($qty > 0 && ! ProductOrderContext::isProductAllowedInContext($product, $isAbo, $is_for)) {
|
if ($qty > 0 && ! ProductOrderContext::isProductAllowedInContext($product, $isAbo, $is_for)) {
|
||||||
return response()->json([
|
return $this->cartUpdateRejected($data, __('msg.cart_product_not_allowed_for_order_type'));
|
||||||
'response' => false,
|
}
|
||||||
'message' => __('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 = '';
|
$image = '';
|
||||||
|
|
@ -831,6 +832,67 @@ class OrderController extends Controller
|
||||||
return response()->json(['response' => true, 'data' => $data, 'html_card' => $html_card, 'html_comp' => $html_comp]);
|
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<string, mixed> $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
|
* Handle updating shipping country
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,12 @@ class CardController extends Controller
|
||||||
{
|
{
|
||||||
$product = Product::find($id);
|
$product = Product::find($id);
|
||||||
if ($product && ProductOrderContext::isProductAllowedInCustomerWebshop($product)) {
|
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 = '';
|
$image = '';
|
||||||
if ($product->images->count()) {
|
if ($product->images->count()) {
|
||||||
$image = $product->images->first()->slug;
|
$image = $product->images->first()->slug;
|
||||||
|
|
@ -61,11 +67,17 @@ class CardController extends Controller
|
||||||
$product = Product::find($id);
|
$product = Product::find($id);
|
||||||
|
|
||||||
if ($product && ProductOrderContext::isProductAllowedInCustomerWebshop($product)) {
|
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 = '';
|
$image = '';
|
||||||
if ($product->images->count()) {
|
if ($product->images->count()) {
|
||||||
$image = $product->images->first()->slug;
|
$image = $product->images->first()->slug;
|
||||||
}
|
}
|
||||||
$quantity = Request::get('quantity') ? Request::get('quantity') : 1;
|
|
||||||
$cartItem = Yard::instance($this->instance)
|
$cartItem = Yard::instance($this->instance)
|
||||||
->add(
|
->add(
|
||||||
$product->id,
|
$product->id,
|
||||||
|
|
@ -122,6 +134,14 @@ class CardController extends Controller
|
||||||
if ($product && $product->is_membership_only) {
|
if ($product && $product->is_membership_only) {
|
||||||
$qty = 1;
|
$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)->update($rowId, $qty);
|
||||||
Yard::instance($this->instance)->reCalculateShippingPrice();
|
Yard::instance($this->instance)->reCalculateShippingPrice();
|
||||||
|
|
|
||||||
42
app/Http/Requests/Abo/AboOneTimeItemRequest.php
Normal file
42
app/Http/Requests/Abo/AboOneTimeItemRequest.php
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Abo;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class AboOneTimeItemRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array<int, mixed>>
|
||||||
|
*/
|
||||||
|
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<string, string>
|
||||||
|
*/
|
||||||
|
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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||||
* @property-read \App\Models\Product $product
|
* @property-read \App\Models\Product $product
|
||||||
* @property-read \App\Models\ShoppingOrder $shopping_order
|
* @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 newModelQuery()
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingOrderItem newQuery()
|
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingOrderItem newQuery()
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingOrderItem query()
|
* @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 whereShoppingOrderId($value)
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingOrderItem whereSlug($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingOrderItem whereSlug($value)
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingOrderItem whereUpdatedAt($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingOrderItem whereUpdatedAt($value)
|
||||||
|
*
|
||||||
* @property float|null $tax_rate
|
* @property float|null $tax_rate
|
||||||
* @property \Illuminate\Support\Carbon|null $deleted_at
|
* @property \Illuminate\Support\Carbon|null $deleted_at
|
||||||
* @property string|null $user_deleted_at
|
* @property string|null $user_deleted_at
|
||||||
|
*
|
||||||
* @method static \Illuminate\Database\Query\Builder|\App\Models\ShoppingOrderItem onlyTrashed()
|
* @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 whereDeletedAt($value)
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingOrderItem whereTaxRate($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\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 withTrashed()
|
||||||
* @method static \Illuminate\Database\Query\Builder|\App\Models\ShoppingOrderItem withoutTrashed()
|
* @method static \Illuminate\Database\Query\Builder|\App\Models\ShoppingOrderItem withoutTrashed()
|
||||||
|
*
|
||||||
* @property int|null $comp
|
* @property int|null $comp
|
||||||
* @property float|null $price_net
|
* @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 whereComp($value)
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingOrderItem wherePriceNet($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingOrderItem wherePriceNet($value)
|
||||||
|
*
|
||||||
* @property int|null $homeparty_id
|
* @property int|null $homeparty_id
|
||||||
* @property-read \App\Models\Homeparty|null $homeparty
|
* @property-read \App\Models\Homeparty|null $homeparty
|
||||||
|
*
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|ShoppingOrderItem whereHomepartyId($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|ShoppingOrderItem whereHomepartyId($value)
|
||||||
|
*
|
||||||
* @property string|null $tax
|
* @property string|null $tax
|
||||||
* @property string|null $price_vk_net
|
* @property string|null $price_vk_net
|
||||||
* @property string|null $discount
|
* @property string|null $discount
|
||||||
* @property int|null $points
|
* @property int|null $points
|
||||||
|
*
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|ShoppingOrderItem whereDiscount($value)
|
* @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 wherePoints($value)
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|ShoppingOrderItem wherePriceVkNet($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|ShoppingOrderItem wherePriceVkNet($value)
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|ShoppingOrderItem whereTax($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|ShoppingOrderItem whereTax($value)
|
||||||
|
*
|
||||||
* @property int|null $shopping_collect_order_id
|
* @property int|null $shopping_collect_order_id
|
||||||
|
*
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|ShoppingOrderItem whereShoppingCollectOrderId($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|ShoppingOrderItem whereShoppingCollectOrderId($value)
|
||||||
|
*
|
||||||
* @mixin \Eloquent
|
* @mixin \Eloquent
|
||||||
*/
|
*/
|
||||||
class ShoppingOrderItem extends Model
|
class ShoppingOrderItem extends Model
|
||||||
|
|
@ -64,6 +76,7 @@ class ShoppingOrderItem extends Model
|
||||||
protected $table = 'shopping_order_items';
|
protected $table = 'shopping_order_items';
|
||||||
|
|
||||||
use SoftDeletes;
|
use SoftDeletes;
|
||||||
|
|
||||||
protected $dates = ['deleted_at'];
|
protected $dates = ['deleted_at'];
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
|
|
@ -73,6 +86,7 @@ class ShoppingOrderItem extends Model
|
||||||
'homeparty_id',
|
'homeparty_id',
|
||||||
'shopping_collect_order_id',
|
'shopping_collect_order_id',
|
||||||
'comp',
|
'comp',
|
||||||
|
'is_abo_addon',
|
||||||
'qty',
|
'qty',
|
||||||
'price',
|
'price',
|
||||||
'price_net',
|
'price_net',
|
||||||
|
|
@ -83,6 +97,7 @@ class ShoppingOrderItem extends Model
|
||||||
'points',
|
'points',
|
||||||
'slug',
|
'slug',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'qty' => 'int',
|
'qty' => 'int',
|
||||||
'price' => 'float',
|
'price' => 'float',
|
||||||
|
|
@ -92,21 +107,38 @@ class ShoppingOrderItem extends Model
|
||||||
'price_vk_net' => 'float',
|
'price_vk_net' => 'float',
|
||||||
'discount' => 'float',
|
'discount' => 'float',
|
||||||
'points' => 'float',
|
'points' => 'float',
|
||||||
|
'is_abo_addon' => 'bool',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function shopping_order()
|
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()
|
public function product()
|
||||||
{
|
{
|
||||||
return $this->belongsTo('App\Models\Product','product_id');
|
return $this->belongsTo('App\Models\Product', 'product_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function homeparty()
|
public function homeparty()
|
||||||
{
|
{
|
||||||
return $this->belongsTo('App\Models\Homeparty','homeparty_id');
|
return $this->belongsTo('App\Models\Homeparty', 'homeparty_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getFormattedPrice()
|
public function getFormattedPrice()
|
||||||
|
|
@ -139,7 +171,6 @@ class ShoppingOrderItem extends Model
|
||||||
return formatNumber($this->attributes['tax']);
|
return formatNumber($this->attributes['tax']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public function getFormattedDiscount()
|
public function getFormattedDiscount()
|
||||||
{
|
{
|
||||||
return cleanNumberFormat($this->attributes['discount']);
|
return cleanNumberFormat($this->attributes['discount']);
|
||||||
|
|
@ -147,7 +178,7 @@ class ShoppingOrderItem extends Model
|
||||||
|
|
||||||
public function getFormattedTotalPriceNet()
|
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)
|
public function setPointsAttribute($value)
|
||||||
|
|
@ -157,6 +188,6 @@ class ShoppingOrderItem extends Model
|
||||||
|
|
||||||
public function getFormattedPoints()
|
public function getFormattedPoints()
|
||||||
{
|
{
|
||||||
return isset($this->attributes['points']) ? formatNumber($this->attributes['points']) : "";
|
return isset($this->attributes['points']) ? formatNumber($this->attributes['points']) : '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
* @property-read User|null $member
|
* @property-read User|null $member
|
||||||
* @property-read \App\Models\ShoppingUser $shopping_user
|
* @property-read \App\Models\ShoppingUser $shopping_user
|
||||||
* @property-read Collection<int, \App\Models\UserAboItem> $user_abo_items
|
* @property-read Collection<int, \App\Models\UserAboItem> $user_abo_items
|
||||||
|
* @property-read Collection<int, \App\Models\UserAboOneTimeItem> $one_time_items
|
||||||
* @property-read int|null $user_abo_items_count
|
* @property-read int|null $user_abo_items_count
|
||||||
* @property-read int|null $user_abo_orders_count
|
* @property-read int|null $user_abo_orders_count
|
||||||
*
|
*
|
||||||
|
|
@ -173,6 +174,11 @@ class UserAbo extends Model
|
||||||
return $this->hasMany(UserAboItem::class);
|
return $this->hasMany(UserAboItem::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function one_time_items()
|
||||||
|
{
|
||||||
|
return $this->hasMany(UserAboOneTimeItem::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function user_abo_item_histories()
|
public function user_abo_item_histories()
|
||||||
{
|
{
|
||||||
return $this->hasMany(UserAboItemHistory::class);
|
return $this->hasMany(UserAboItemHistory::class);
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ use Yard;
|
||||||
* @property Carbon|null $updated_at
|
* @property Carbon|null $updated_at
|
||||||
* @property Product $product
|
* @property Product $product
|
||||||
* @property UserAbo $user_abo
|
* @property UserAbo $user_abo
|
||||||
* @package App\Models
|
*
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|UserAboItem newModelQuery()
|
* @method static \Illuminate\Database\Eloquent\Builder|UserAboItem newModelQuery()
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|UserAboItem newQuery()
|
* @method static \Illuminate\Database\Eloquent\Builder|UserAboItem newQuery()
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|UserAboItem query()
|
* @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 whereStatus($value)
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|UserAboItem whereUpdatedAt($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|UserAboItem whereUpdatedAt($value)
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|UserAboItem whereUserAboId($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|UserAboItem whereUserAboId($value)
|
||||||
|
*
|
||||||
* @mixin \Eloquent
|
* @mixin \Eloquent
|
||||||
*/
|
*/
|
||||||
class UserAboItem extends Model
|
class UserAboItem extends Model
|
||||||
{
|
{
|
||||||
protected $table = 'user_abo_items';
|
protected $table = 'user_abo_items';
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'user_abo_id' => 'int',
|
'user_abo_id' => 'int',
|
||||||
'product_id' => 'int',
|
'product_id' => 'int',
|
||||||
'comp' => 'int',
|
'comp' => 'int',
|
||||||
'qty' => 'int',
|
'qty' => 'int',
|
||||||
'status' => 'int'
|
'status' => 'int',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'user_abo_id',
|
'user_abo_id',
|
||||||
'product_id',
|
'product_id',
|
||||||
'comp',
|
'comp',
|
||||||
'qty',
|
'qty',
|
||||||
'status'
|
'status',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function product()
|
public function product()
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Product::class);
|
return $this->belongsTo(Product::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function user_abo()
|
public function user_abo()
|
||||||
{
|
{
|
||||||
return $this->belongsTo(UserAbo::class);
|
return $this->belongsTo(UserAbo::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getPrice()
|
public function getPrice()
|
||||||
{
|
{
|
||||||
$ufactor = $this->user_abo->is_for === 'me' ? true : false;
|
$ufactor = $this->user_abo->is_for === 'me' ? true : false;
|
||||||
$tax_free = $ufactor ? true : Yard::instance('shopping')->getUserTaxFree();
|
$tax_free = $ufactor ? true : Yard::instance(\App\Services\AboOrderCart::INSTANCE)->getUserTaxFree();
|
||||||
$userCountry = Yard::instance('shopping')->getUserCountry();
|
$userCountry = Yard::instance(\App\Services\AboOrderCart::INSTANCE)->getUserCountry();
|
||||||
return $this->product->getPriceWith($tax_free, $ufactor, $userCountry);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
return $this->product->getPriceWith($tax_free, $ufactor, $userCountry);
|
||||||
|
}
|
||||||
|
|
||||||
public function getFormattedPrice(){
|
public function getFormattedPrice()
|
||||||
/** der Preis wird für den User berechnet */
|
{
|
||||||
return Util::formatNumber($this->getPrice());
|
/** der Preis wird für den User berechnet */
|
||||||
}
|
return Util::formatNumber($this->getPrice());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFormattedTotalPrice()
|
||||||
public function getFormattedTotalPrice(){
|
{
|
||||||
/** der Preis wird für den User berechnet */
|
/** der Preis wird für den User berechnet */
|
||||||
return Util::formatNumber($this->getPrice() * $this->qty);
|
return Util::formatNumber($this->getPrice() * $this->qty);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
101
app/Models/UserAboOneTimeItem.php
Normal file
101
app/Models/UserAboOneTimeItem.php
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Services\Util;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class UserAboOneTimeItem
|
||||||
|
*
|
||||||
|
* Einmalig dem Abo hinzugefügte Produkte aus dem normalen Bestellsortiment.
|
||||||
|
* Bewusst getrennt von {@see UserAboItem}, damit diese Positionen nie in die
|
||||||
|
* dauerhafte Abo-Logik geraten und nach erfolgreicher Ausführung gelöscht werden.
|
||||||
|
*
|
||||||
|
* @property int $id
|
||||||
|
* @property int $user_abo_id
|
||||||
|
* @property int $product_id
|
||||||
|
* @property int|null $comp
|
||||||
|
* @property int $qty
|
||||||
|
* @property int|null $confirmed_qty
|
||||||
|
* @property \Illuminate\Support\Carbon|null $confirmed_at
|
||||||
|
* @property float|null $price
|
||||||
|
* @property float|null $price_net
|
||||||
|
* @property float|null $tax_rate
|
||||||
|
* @property float|null $tax
|
||||||
|
* @property float|null $price_vk_net
|
||||||
|
* @property float|null $discount
|
||||||
|
* @property int|null $points
|
||||||
|
* @property int $status
|
||||||
|
* @property-read Product $product
|
||||||
|
* @property-read UserAbo $user_abo
|
||||||
|
*/
|
||||||
|
class UserAboOneTimeItem extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
use SoftDeletes;
|
||||||
|
|
||||||
|
protected $table = 'user_abo_one_time_items';
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'user_abo_id' => '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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,12 @@ class AboHelper
|
||||||
*/
|
*/
|
||||||
public const MIN_DAYS_UNTIL_FIRST_ABO_EXECUTION = 10;
|
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 = [
|
public static $txaction_filter_text = [
|
||||||
'paid' => 'paymend_paid',
|
'paid' => 'paymend_paid',
|
||||||
'appointed' => 'paymend_open',
|
'appointed' => 'paymend_open',
|
||||||
|
|
@ -168,6 +174,41 @@ class AboHelper
|
||||||
return $nextDate->gt($date) ? $nextDate : $nextDate->addMonth(1);
|
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)
|
public static function getFirstAboDate($date, $abo_interval)
|
||||||
{
|
{
|
||||||
$reference = Carbon::parse($date)->startOfDay();
|
$reference = Carbon::parse($date)->startOfDay();
|
||||||
|
|
|
||||||
181
app/Services/AboOneTimeService.php
Normal file
181
app/Services/AboOneTimeService.php
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Product;
|
||||||
|
use App\Models\UserAbo;
|
||||||
|
use App\Models\UserAboOneTimeItem;
|
||||||
|
|
||||||
|
class AboOneTimeService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Verarbeitet eine Einmal-Artikel-Aktion (add/update/remove) für ein Abo.
|
||||||
|
*
|
||||||
|
* Erwartet einen bereits initialisierten Yard (für die Snapshot-Berechnung beim
|
||||||
|
* Hinzufügen). Gibt eine Fehlermeldung zurück oder null bei Erfolg.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,18 +5,66 @@ namespace App\Services;
|
||||||
use App\Models\Product;
|
use App\Models\Product;
|
||||||
use App\Models\ShippingCountry;
|
use App\Models\ShippingCountry;
|
||||||
use App\Models\ShoppingUser;
|
use App\Models\ShoppingUser;
|
||||||
|
use App\Models\UserAbo;
|
||||||
use App\Models\UserAboItem;
|
use App\Models\UserAboItem;
|
||||||
use App\User;
|
use App\User;
|
||||||
use Yard;
|
use Yard;
|
||||||
|
|
||||||
class AboOrderCart
|
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 $user_abo;
|
||||||
|
|
||||||
private static $is_for;
|
private static $is_for;
|
||||||
|
|
||||||
private static $customer_detail;
|
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)
|
public static function initYard($user_abo)
|
||||||
{
|
{
|
||||||
|
|
||||||
|
|
@ -26,8 +74,11 @@ class AboOrderCart
|
||||||
self::$is_for = null;
|
self::$is_for = null;
|
||||||
self::$customer_detail = 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 komplett leeren - wichtig für Batch-Verarbeitung mehrerer Abos
|
||||||
$yard = Yard::instance('shopping');
|
$yard = Yard::instance(self::INSTANCE);
|
||||||
$itemsBeforeDestroy = $yard->content()->count();
|
$itemsBeforeDestroy = $yard->content()->count();
|
||||||
$yard->destroy();
|
$yard->destroy();
|
||||||
|
|
||||||
|
|
@ -81,7 +132,7 @@ class AboOrderCart
|
||||||
|
|
||||||
// WICHTIG: Yard IMMER leeren, um sicherzustellen, dass keine Produkte aus vorherigen Aufrufen vorhanden sind
|
// 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)
|
// 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();
|
$itemsBefore = $yard->content()->count();
|
||||||
$yard->destroy();
|
$yard->destroy();
|
||||||
|
|
||||||
|
|
@ -113,9 +164,9 @@ class AboOrderCart
|
||||||
foreach ($abo_items as $abo_item) {
|
foreach ($abo_items as $abo_item) {
|
||||||
self::addProductToCart($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();
|
$user_abo->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -123,12 +174,12 @@ class AboOrderCart
|
||||||
{
|
{
|
||||||
|
|
||||||
$product = Product::find($item->product_id);
|
$product = Product::find($item->product_id);
|
||||||
$tax_free = Yard::instance('shopping')->getUserTaxFree();
|
$tax_free = Yard::instance(self::INSTANCE)->getUserTaxFree();
|
||||||
$user_country = Yard::instance('shopping')->getUserCountry();
|
$user_country = Yard::instance(self::INSTANCE)->getUserCountry();
|
||||||
|
|
||||||
if ($product) {
|
if ($product) {
|
||||||
if ($item->comp) {
|
if ($item->comp) {
|
||||||
$cartItem = Yard::instance('shopping')->add(
|
$cartItem = Yard::instance(self::INSTANCE)->add(
|
||||||
$product->id,
|
$product->id,
|
||||||
$product->getLang('name'),
|
$product->getLang('name'),
|
||||||
1,
|
1,
|
||||||
|
|
@ -149,7 +200,7 @@ class AboOrderCart
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (self::$is_for === 'ot-customer' || self::$is_for === 'abo-ot-customer') {
|
if (self::$is_for === 'ot-customer' || self::$is_for === 'abo-ot-customer') {
|
||||||
$cartItem = Yard::instance('shopping')
|
$cartItem = Yard::instance(self::INSTANCE)
|
||||||
->add(
|
->add(
|
||||||
$product->id,
|
$product->id,
|
||||||
$product->getLang('name'),
|
$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]
|
['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 {
|
} else {
|
||||||
$cartItem = Yard::instance('shopping')
|
$cartItem = Yard::instance(self::INSTANCE)
|
||||||
->add(
|
->add(
|
||||||
$product->id,
|
$product->id,
|
||||||
$product->getLang('name'),
|
$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)
|
public static function checkNumOfCompProducts($user_abo)
|
||||||
{
|
{
|
||||||
|
|
||||||
if ($user_abo->is_for === 'me') {
|
if ($user_abo->is_for === 'me') {
|
||||||
$needNumComp = Yard::instance('shopping')->getNumComp();
|
$needNumComp = Yard::instance(self::INSTANCE)->getNumComp();
|
||||||
if ($needNumComp > 0) {
|
$UserAboItems = UserAboItem::where('user_abo_id', $user_abo->id)
|
||||||
$UserAboItems = UserAboItem::where('user_abo_id', $user_abo->id)->where('comp', '>', 0)->get();
|
->where('comp', '>', 0)
|
||||||
if (count($UserAboItems) === $needNumComp) {
|
->orderBy('comp')
|
||||||
return true;
|
->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
|
for ($comp = $UserAboItems->count() + 1; $comp <= $needNumComp; $comp++) {
|
||||||
if (count($UserAboItems) < $needNumComp) {
|
$UserAboItem = UserAboItem::create([
|
||||||
$product = Product::whereActive(true)->where('shipping_addon', true)->whereJsonContains('show_on', '12')->orderBy('pos', 'DESC')->first();
|
'user_abo_id' => $user_abo->id,
|
||||||
for ($i = count($UserAboItems); $i <= $needNumComp; $i++) {
|
'product_id' => $product->id,
|
||||||
$UserAboItem = UserAboItem::create([
|
'comp' => $comp,
|
||||||
'user_abo_id' => $user_abo->id,
|
'qty' => 1,
|
||||||
'product_id' => $product->id,
|
'status' => 1,
|
||||||
'comp' => $i + 1,
|
]);
|
||||||
'qty' => 1,
|
AboItemHistoryService::logSystemCompAdded($user_abo, $UserAboItem);
|
||||||
'status' => 1,
|
self::addProductToCart($UserAboItem);
|
||||||
]);
|
}
|
||||||
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 (Yard::instance(self::INSTANCE)->content() as $row) {
|
||||||
foreach ($UserAboItems as $UserAboItem) {
|
if (($row->options->comp ?? 0) > $needNumComp) {
|
||||||
if ($UserAboItem->comp > $needNumComp) {
|
Yard::instance(self::INSTANCE)->remove($row->rowId);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,34 @@ class Yard extends Cart
|
||||||
$this->calculateShippingPrice();
|
$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')
|
public function setShippingCountryWithPrice($shipping_country_id, $shipping_is_for = 'ot-member')
|
||||||
{
|
{
|
||||||
$this->shipping_country_id = $shipping_country_id;
|
$this->shipping_country_id = $shipping_country_id;
|
||||||
|
|
@ -276,9 +304,10 @@ class Yard extends Cart
|
||||||
// if(!$shipping_price){
|
// 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) {
|
if (! $shipping_price) {
|
||||||
$shipping_price = $shipping->shipping_prices->first();
|
$shipping_price = $shipping->shipping_prices->sortByDesc('weight_to')->first();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($shipping_price) {
|
if ($shipping_price) {
|
||||||
|
|
|
||||||
32
database/factories/UserAboOneTimeItemFactory.php
Normal file
32
database/factories/UserAboOneTimeItemFactory.php
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\UserAboOneTimeItem;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<UserAboOneTimeItem>
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
use Illuminate\Database\Migrations\Migration;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
return new class extends Migration
|
return new class extends Migration
|
||||||
{
|
{
|
||||||
|
|
@ -10,6 +11,10 @@ return new class extends Migration
|
||||||
*/
|
*/
|
||||||
public function up(): void
|
public function up(): void
|
||||||
{
|
{
|
||||||
|
if (! Schema::hasTable('trans_languages')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
DB::table('trans_languages')->updateOrInsert(
|
DB::table('trans_languages')->updateOrInsert(
|
||||||
['language' => 'fr'],
|
['language' => 'fr'],
|
||||||
[
|
[
|
||||||
|
|
@ -25,6 +30,10 @@ return new class extends Migration
|
||||||
*/
|
*/
|
||||||
public function down(): void
|
public function down(): void
|
||||||
{
|
{
|
||||||
|
if (! Schema::hasTable('trans_languages')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
DB::table('trans_languages')
|
DB::table('trans_languages')
|
||||||
->where('language', 'fr')
|
->where('language', 'fr')
|
||||||
->delete();
|
->delete();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('user_abo_one_time_items', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('shopping_order_items', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('user_abo_one_time_items', function (Blueprint $table) {
|
||||||
|
$table->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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
387
dev/2026-06-05/ENTWICKLUNGSPLAN-ABO-EINMALPRODUKTE.md
Normal file
387
dev/2026-06-05/ENTWICKLUNGSPLAN-ABO-EINMALPRODUKTE.md
Normal file
|
|
@ -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, Positionspreis 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 |
|
||||||
297
public/js/iq-abo-onetime.js
Normal file
297
public/js/iq-abo-onetime.js
Normal file
|
|
@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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 _self = this;
|
||||||
var $confirmModal = $(_self.confirmModal);
|
var $confirmModal = $(_self.confirmModal);
|
||||||
var titleKey = _self.addOnlyMode ? "title-add-only" : "title-normal";
|
var confirmOptions = options || {};
|
||||||
var warningKey = _self.addOnlyMode ? "warning-add-only" : "warning-normal";
|
var confirmType = confirmOptions.type || "add";
|
||||||
$("#modal-confirm-add-label").text($confirmModal.data(titleKey));
|
var additionalCost = _self.parseCurrency(productPrice);
|
||||||
$("#confirm-add-warning-text").text($confirmModal.data(warningKey));
|
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-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-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;
|
_self.pendingAction = callback;
|
||||||
$confirmModal.modal("show");
|
$confirmModal.modal("show");
|
||||||
},
|
},
|
||||||
|
|
@ -76,15 +126,21 @@ var IqModalCart = {
|
||||||
var productPrice = _obj.data("product-price") || "";
|
var productPrice = _obj.data("product-price") || "";
|
||||||
var $modal = $(_self.modal);
|
var $modal = $(_self.modal);
|
||||||
$modal.one("hidden.bs.modal", function() {
|
$modal.one("hidden.bs.modal", function() {
|
||||||
_self.showConfirm(productName, productPrice, "1x", function() {
|
_self.showConfirm(
|
||||||
_self
|
productName,
|
||||||
.performRequest({
|
productPrice,
|
||||||
product_id: _obj.data("product-id"),
|
"1x",
|
||||||
qty: 1,
|
function() {
|
||||||
action: "addProduct",
|
_self
|
||||||
})
|
.performRequest({
|
||||||
.done(_self.refreshView);
|
product_id: _obj.data("product-id"),
|
||||||
});
|
qty: 1,
|
||||||
|
action: "addProduct",
|
||||||
|
})
|
||||||
|
.done(_self.refreshView);
|
||||||
|
},
|
||||||
|
{ type: "add" },
|
||||||
|
);
|
||||||
});
|
});
|
||||||
$modal.modal("hide");
|
$modal.modal("hide");
|
||||||
},
|
},
|
||||||
|
|
@ -117,9 +173,11 @@ var IqModalCart = {
|
||||||
_self.remove_from_cart($(this), _obj);
|
_self.remove_from_cart($(this), _obj);
|
||||||
});
|
});
|
||||||
if (_self.is_for === "me" || _self.is_for === "abo-me") {
|
if (_self.is_for === "me" || _self.is_for === "abo-me") {
|
||||||
$('input[name^="' + _self.comp_products + '"]').on("change", function() {
|
$('input[name^="' + _self.comp_products + '"]')
|
||||||
_self.update_comp_product($(this));
|
.off("change")
|
||||||
});
|
.on("change", function() {
|
||||||
|
_self.update_comp_product($(this));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
update_comp_product: function(_obj) {
|
update_comp_product: function(_obj) {
|
||||||
|
|
@ -149,10 +207,16 @@ var IqModalCart = {
|
||||||
var productPrice = _obj.data("product-price") || "";
|
var productPrice = _obj.data("product-price") || "";
|
||||||
var qtyInfo = currentQty + " \u2192 " + qty;
|
var qtyInfo = currentQty + " \u2192 " + qty;
|
||||||
console.log(qtyInfo);
|
console.log(qtyInfo);
|
||||||
_self.showConfirm(productName, productPrice, qtyInfo, function() {
|
_self.showConfirm(
|
||||||
input.val(qty);
|
productName,
|
||||||
_self.update_cart(_holder, _obj, qty);
|
productPrice,
|
||||||
});
|
qtyInfo,
|
||||||
|
function() {
|
||||||
|
input.val(qty);
|
||||||
|
_self.update_cart(_holder, _obj, qty);
|
||||||
|
},
|
||||||
|
{ type: "increase" },
|
||||||
|
);
|
||||||
},
|
},
|
||||||
remove_product: function(_obj, _holder) {
|
remove_product: function(_obj, _holder) {
|
||||||
var _self = this;
|
var _self = this;
|
||||||
|
|
@ -200,12 +264,16 @@ var IqModalCart = {
|
||||||
var obj = $(_self.cart_holder);
|
var obj = $(_self.cart_holder);
|
||||||
obj.html(data.html_cart);
|
obj.html(data.html_cart);
|
||||||
$(_self.insert_show_total_order).html(data.html_total);
|
$(_self.insert_show_total_order).html(data.html_total);
|
||||||
|
if (data.html_summary) {
|
||||||
|
$("#combined_summary_card").html(data.html_summary);
|
||||||
|
}
|
||||||
$(_self.modal).modal("hide");
|
$(_self.modal).modal("hide");
|
||||||
if ($(_self.comp_holder)) {
|
if (data.html_comp !== undefined) {
|
||||||
$(_self.comp_holder).html(data.html_comp);
|
$(_self.comp_holder).html(data.html_comp);
|
||||||
}
|
}
|
||||||
if ($("#value-amount")) {
|
if ($("#value-amount")) {
|
||||||
$("#value-amount").html(data.amount);
|
$("#value-amount").html(data.amount);
|
||||||
|
$(_self.confirmModal).data("current-total", data.amount);
|
||||||
}
|
}
|
||||||
_self.reInitCart(obj);
|
_self.reInitCart(obj);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ var IqShoppingCart = {
|
||||||
oTable: null,
|
oTable: null,
|
||||||
table_input: '.table-input-event-onchange',
|
table_input: '.table-input-event-onchange',
|
||||||
cart_input: '.cart-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',
|
remove_item: '.remove_item_form_cart',
|
||||||
shipping_state: '#change_shipping_state',
|
shipping_state: '#change_shipping_state',
|
||||||
comp_products: 'switchers-comp-product',
|
comp_products: 'switchers-comp-product',
|
||||||
|
|
@ -57,6 +59,12 @@ var IqShoppingCart = {
|
||||||
$(_self.cart_input).on('change', function(){
|
$(_self.cart_input).on('change', function(){
|
||||||
_self.update_input_cart($(this));
|
_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){
|
$(_self.remove_item).on('click', function(event){
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
_self.update_cart_database($(this).data('product-id'), 0);
|
_self.update_cart_database($(this).data('product-id'), 0);
|
||||||
|
|
@ -88,6 +96,18 @@ var IqShoppingCart = {
|
||||||
_obj.val(qty);
|
_obj.val(qty);
|
||||||
_self.update_cart_database(_obj.data('product-id'), 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){
|
update_comp_product: function (_obj){
|
||||||
var _self = this;
|
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'})
|
_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){
|
refreshItemsAndView: function (data){
|
||||||
var _self = IqShoppingCart;
|
var _self = IqShoppingCart;
|
||||||
if (data && data.response === false && data.message) {
|
if (data && data.response === false && data.message) {
|
||||||
alert(data.message);
|
_self.showCartError(data.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$(_self.card_holder).html(data.html_card);
|
$(_self.card_holder).html(data.html_card);
|
||||||
|
|
@ -185,7 +219,7 @@ var IqShoppingCart = {
|
||||||
refreshDatabaseAndView: function (data) {
|
refreshDatabaseAndView: function (data) {
|
||||||
var _self = IqShoppingCart;
|
var _self = IqShoppingCart;
|
||||||
if (data && data.response === false && data.message) {
|
if (data && data.response === false && data.message) {
|
||||||
alert(data.message);
|
_self.showCartError(data.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$(_self.card_holder).html(data.html_card);
|
$(_self.card_holder).html(data.html_card);
|
||||||
|
|
|
||||||
|
|
@ -75,10 +75,21 @@ return [
|
||||||
'error_add_only_no_remove' => 'Das Entfernen von Produkten ist während der Mindestlaufzeit nicht möglich.',
|
'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' => 'Produkt hinzufügen bestätigen',
|
||||||
'confirm_add_title_normal' => 'Produkt zum Abo hinzufügen',
|
'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' => '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_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_cancel' => 'Abbrechen',
|
||||||
'confirm_add_ok' => 'Ja, hinzufügen',
|
'confirm_add_ok' => 'Zahlungspflichtig aktualisieren',
|
||||||
'add_product' => 'Produkt hinzufügen',
|
'add_product' => 'Produkt hinzufügen',
|
||||||
'product_prices_career_level_info' => 'Die Produktpreise werden entsprechend Deinem Karriere-Level <strong>:user_level_name</strong> abzüglich <strong>:user_level_margin %</strong> Marge angezeigt und brechnet.',
|
'product_prices_career_level_info' => 'Die Produktpreise werden entsprechend Deinem Karriere-Level <strong>:user_level_name</strong> abzüglich <strong>:user_level_margin %</strong> 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 <strong>:user_level_name</strong> Provision <strong>:user_level_margin %</strong>.',
|
'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 <strong>:user_level_name</strong> Provision <strong>:user_level_margin %</strong>.',
|
||||||
|
|
@ -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!',
|
'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',
|
'abo_item_not_found' => 'Abo-Position nicht gefunden',
|
||||||
'product_not_found' => 'Produkt 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',
|
'create_abo' => 'Abo erstellen',
|
||||||
'info' => 'Info',
|
'info' => 'Info',
|
||||||
'data' => 'Daten',
|
'data' => 'Daten',
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ return [
|
||||||
'shopping_user_not_found' => 'Fehler: Es wurde kein ShoppingUser gefunden',
|
'shopping_user_not_found' => 'Fehler: Es wurde kein ShoppingUser gefunden',
|
||||||
'account_released' => 'Account freigeschaltet',
|
'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_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.') }}
|
{{ __('msg.') }}
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,7 @@ return [
|
||||||
'ship_to_this_email_info' => 'Der Bestelllink wird Deinem Kunden an folgenden E-Mail-Adresse gesendet:',
|
'ship_to_this_email_info' => 'Der Bestelllink wird Deinem Kunden an folgenden E-Mail-Adresse gesendet:',
|
||||||
'shipping' => 'Versand',
|
'shipping' => 'Versand',
|
||||||
'shipping_compensation_product' => 'Versand Kompensationsprodukt',
|
'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',
|
'shipping_costs' => 'Versandkosten',
|
||||||
'shopping_cart' => 'Warenkorb',
|
'shopping_cart' => 'Warenkorb',
|
||||||
'shopping_cart_delete' => 'Warenkorb löschen',
|
'shopping_cart_delete' => 'Warenkorb löschen',
|
||||||
|
|
|
||||||
|
|
@ -66,10 +66,21 @@ return [
|
||||||
'error_add_only_no_remove' => 'Removing products is not possible during the minimum duration period.',
|
'error_add_only_no_remove' => 'Removing products is not possible during the minimum duration period.',
|
||||||
'confirm_add_title' => 'Confirm adding product',
|
'confirm_add_title' => 'Confirm adding product',
|
||||||
'confirm_add_title_normal' => 'Add product to subscription',
|
'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' => '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_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_cancel' => 'Cancel',
|
||||||
'confirm_add_ok' => 'Yes, add product',
|
'confirm_add_ok' => 'Update with payment obligation',
|
||||||
'add_product' => 'Add product',
|
'add_product' => 'Add product',
|
||||||
'product_prices_career_level_info' => 'Product prices are displayed and calculated according to your career level <strong>:user_level_name</strong> minus <strong>:user_level_margin %</strong> margin.',
|
'product_prices_career_level_info' => 'Product prices are displayed and calculated according to your career level <strong>:user_level_name</strong> minus <strong>:user_level_margin %</strong> 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 <strong>:user_level_name</strong> commission <strong>:user_level_margin %</strong>.',
|
'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 <strong>:user_level_name</strong> commission <strong>:user_level_margin %</strong>.',
|
||||||
|
|
@ -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!',
|
'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',
|
'abo_item_not_found' => 'Subscription item not found',
|
||||||
'product_not_found' => 'Product 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',
|
'create_abo' => 'Create subscription',
|
||||||
'info' => 'Info',
|
'info' => 'Info',
|
||||||
'data' => 'Data',
|
'data' => 'Data',
|
||||||
|
|
|
||||||
|
|
@ -39,4 +39,5 @@ return [
|
||||||
'' => 'Your shopping cart is empty, please add products first',
|
'' => '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_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.',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,7 @@ return [
|
||||||
'ship_to_this_email_info' => 'The order link will be sent to your customer at the following email address:',
|
'ship_to_this_email_info' => 'The order link will be sent to your customer at the following email address:',
|
||||||
'shipping' => 'shipment',
|
'shipping' => 'shipment',
|
||||||
'shipping_compensation_product' => 'shipping compensation product',
|
'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',
|
'shipping_costs' => 'shipping',
|
||||||
'shopping_cart' => 'shopping cart',
|
'shopping_cart' => 'shopping cart',
|
||||||
'shopping_cart_delete' => 'clear cart',
|
'shopping_cart_delete' => 'clear cart',
|
||||||
|
|
|
||||||
|
|
@ -66,10 +66,21 @@ return [
|
||||||
'error_add_only_no_remove' => 'No es posible eliminar productos durante el periodo de duración mínima.',
|
'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' => 'Confirmar agregar producto',
|
||||||
'confirm_add_title_normal' => 'Agregar producto a la suscripción',
|
'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' => '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_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_cancel' => 'Cancelar',
|
||||||
'confirm_add_ok' => 'Sí, agregar',
|
'confirm_add_ok' => 'Actualizar con obligación de pago',
|
||||||
'add_product' => 'Añadir producto',
|
'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 <strong>:user_level_name</strong> menos <strong>:user_level_margin %</strong> de margen.',
|
'product_prices_career_level_info' => 'Los precios de los productos se muestran y calculan según su nivel de carrera <strong>:user_level_name</strong> menos <strong>:user_level_margin %</strong> 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 <strong>:user_level_name</strong> comisión <strong>:user_level_margin %</strong>.',
|
'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 <strong>:user_level_name</strong> comisión <strong>:user_level_margin %</strong>.',
|
||||||
|
|
@ -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!',
|
'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',
|
'abo_item_not_found' => 'Artículo de suscripción no encontrado',
|
||||||
'product_not_found' => 'Producto 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',
|
'create_abo' => 'Crear suscripción',
|
||||||
'info' => 'Info',
|
'info' => 'Info',
|
||||||
'data' => 'Datos',
|
'data' => 'Datos',
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,42 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
return array (
|
return [
|
||||||
'VATID_could_not_be_validated' => 'No se pudo validar el ID de IVA, verifique su entrada',
|
'VATID_could_not_be_validated' => 'No se pudo validar el ID de IVA, verifique su entrada',
|
||||||
'VATID_successfully_entered' => 'ID de IVA ingresado exitosamente',
|
'VATID_successfully_entered' => 'ID de IVA ingresado exitosamente',
|
||||||
'abo_deaktivert' => 'opción de suscripción deshabilitada',
|
'abo_deaktivert' => 'opción de suscripción deshabilitada',
|
||||||
'account_released' => 'Cuenta activada',
|
'account_released' => 'Cuenta activada',
|
||||||
'booked_package_has_been_changed' => 'El paquete reservado ha sido modificado.',
|
'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',
|
'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.',
|
'cart_max_weight_reached' => 'Se ha alcanzado el peso máximo de envío. No se pueden añadir más productos.',
|
||||||
'contact_delete' => 'contacto eliminado',
|
'compensation_products_cannot_be_0' => 'Error: Los productos de compensación no pueden ser 0.',
|
||||||
'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.',
|
'contact_delete' => 'contacto eliminado',
|
||||||
'error_checkbox_not_confirm' => 'Error: Casilla de verificación no confirmada',
|
'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_occurred_with_order' => 'Se produjo un error al realizar el pedido.',
|
'error_checkbox_not_confirm' => 'Error: Casilla de verificación no confirmada',
|
||||||
'file_deleted' => 'archivo eliminado',
|
'error_occurred_with_order' => 'Se produjo un error al realizar el pedido.',
|
||||||
'file_empty' => '"archivo vacío"',
|
'file_deleted' => 'archivo eliminado',
|
||||||
'file_not_found' => 'archivo no encontrado',
|
'file_empty' => '"archivo vacío"',
|
||||||
'file_uploaded' => 'archivo subido',
|
'file_not_found' => 'archivo no encontrado',
|
||||||
'homeparty_delete' => 'fiesta de tiempo de espera eliminada',
|
'file_uploaded' => 'archivo subido',
|
||||||
'homeparty_guest_delete' => 'invitado a la fiesta de tiempo de espera eliminado',
|
'homeparty_delete' => 'fiesta de tiempo de espera eliminada',
|
||||||
'link_for_homeparty_not_found' => 'El enlace para la fiesta de tiempo de espera no se encontró o ya no está activo.',
|
'homeparty_guest_delete' => 'invitado a la fiesta de tiempo de espera eliminado',
|
||||||
'no_change_made' => 'no se realizó ningún cambio',
|
'link_for_homeparty_not_found' => 'El enlace para la fiesta de tiempo de espera no se encontró o ya no está activo.',
|
||||||
'no_id_card_deposited_please_upload_first' => 'No se proporcionó ninguna identificación, cárguela primero',
|
'no_change_made' => 'no se realizó ningún cambio',
|
||||||
'no_trade_licence_deposited_please_upload_first' => 'No hay ninguna licencia comercial almacenada; cárguela primero',
|
'no_id_card_deposited_please_upload_first' => 'No se proporcionó ninguna identificación, cárguela primero',
|
||||||
'please_enter_reason_why_you_not_need_trade_licence' => 'Proporcione una razón por la cual no necesita una licencia comercial',
|
'no_trade_licence_deposited_please_upload_first' => 'No hay ninguna licencia comercial almacenada; cárguela primero',
|
||||||
'please_select_compensation_product' => 'Por favor seleccione un producto de compensación',
|
'please_enter_reason_why_you_not_need_trade_licence' => 'Proporcione una razón por la cual no necesita una licencia comercial',
|
||||||
'please_select_count_compensation_products' => 'Por favor seleccione: contar productos de compensación',
|
'please_select_compensation_product' => 'Por favor seleccione un producto de compensación',
|
||||||
'reverse_charge_procedure_and_VATID_deleted' => 'Se elimina el procedimiento de inversión del cargo y el número de IVA',
|
'please_select_count_compensation_products' => 'Por favor seleccione: contar productos de compensación',
|
||||||
'shipping_cost_cannot_be_0' => 'Error: el costo de envío no puede ser 0',
|
'reverse_charge_procedure_and_VATID_deleted' => 'Se elimina el procedimiento de inversión del cargo y el número de IVA',
|
||||||
'shipping_costs_were_not_calculated_correctly' => 'Error: los costos de envío no se calcularon correctamente',
|
'shipping_cost_cannot_be_0' => 'Error: el costo de envío no puede ser 0',
|
||||||
'shipping_country_was_not_correctly' => 'Error: El país de envío no se procesó correctamente en el carrito de compras',
|
'shipping_costs_were_not_calculated_correctly' => 'Error: los costos de envío no se calcularon correctamente',
|
||||||
'shipping_country_was_not_found' => 'Error: País de envío no encontrado',
|
'shipping_country_was_not_correctly' => 'Error: El país de envío no se procesó correctamente en el carrito de compras',
|
||||||
'shopping_cart_was_not_user_shop' => 'Error: El consultor no tiene tienda, el pedido no puede continuar',
|
'shipping_country_was_not_found' => 'Error: País de envío no encontrado',
|
||||||
'shopping_cart_was_shipping_free' => 'Error: El carrito de compras se especificó como envío gratuito',
|
'shopping_cart_was_not_user_shop' => 'Error: El consultor no tiene tienda, el pedido no puede continuar',
|
||||||
'shopping_instance_not_found' => 'Error: No se ha encontrado ninguna ShoppingInstance',
|
'shopping_cart_was_shipping_free' => 'Error: El carrito de compras se especificó como envío gratuito',
|
||||||
'shopping_user_not_found' => 'Error: No se ha encontrado ningún ShoppingUser',
|
'shopping_instance_not_found' => 'Error: No se ha encontrado ninguna ShoppingInstance',
|
||||||
'user_not_found' => 'El consultor no fue encontrado.<br>La cuenta ha sido desactivada o eliminada.',
|
'shopping_user_not_found' => 'Error: No se ha encontrado ningún ShoppingUser',
|
||||||
'your_shopping_cart_is_empty_please_add_products_first' =>
|
'user_not_found' => 'El consultor no fue encontrado.<br>La cuenta ha sido desactivada o eliminada.',
|
||||||
array (
|
'your_shopping_cart_is_empty_please_add_products_first' => [
|
||||||
'' => 'Su carrito de compras está vacío, agregue productos primero',
|
'' => 'Su carrito de compras está vacío, agregue productos primero',
|
||||||
),
|
],
|
||||||
);
|
];
|
||||||
|
|
|
||||||
|
|
@ -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',
|
'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' => 'envío',
|
||||||
'shipping_compensation_product' => 'producto de compensación de 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',
|
'shipping_costs' => 'envío',
|
||||||
'shopping_cart' => 'carro de la compra',
|
'shopping_cart' => 'carro de la compra',
|
||||||
'shopping_cart_delete' => 'vaciar carrito',
|
'shopping_cart_delete' => 'vaciar carrito',
|
||||||
|
|
|
||||||
|
|
@ -75,10 +75,21 @@ return [
|
||||||
'error_add_only_no_remove' => 'La suppression de produits n’est pas possible pendant la durée minimale.',
|
'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' => 'Confirmer l’ajout du produit',
|
||||||
'confirm_add_title_normal' => 'Ajouter le produit à l’abonnement',
|
'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' => '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_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_cancel' => 'Annuler',
|
||||||
'confirm_add_ok' => 'Oui, ajouter',
|
'confirm_add_ok' => 'Mettre à jour avec obligation de paiement',
|
||||||
'add_product' => 'Ajouter le produit',
|
'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 <strong>:user_level_name</strong> moins <strong>:user_level_margin %</strong> de marge.',
|
'product_prices_career_level_info' => 'Les prix produits sont affichés et calculés selon votre niveau de carrière <strong>:user_level_name</strong> moins <strong>:user_level_margin %</strong> 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 <strong>:user_level_name</strong>, commission <strong>:user_level_margin %</strong>.',
|
'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 <strong>:user_level_name</strong>, commission <strong>:user_level_margin %</strong>.',
|
||||||
|
|
@ -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 !',
|
'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',
|
'abo_item_not_found' => 'Position d’abonnement introuvable',
|
||||||
'product_not_found' => 'Produit 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',
|
'create_abo' => 'Créer un abonnement',
|
||||||
'info' => 'Info',
|
'info' => 'Info',
|
||||||
'data' => 'Données',
|
'data' => 'Données',
|
||||||
|
|
|
||||||
|
|
@ -37,4 +37,5 @@ return [
|
||||||
'shopping_user_not_found' => 'Erreur : aucun ShoppingUser trouvé',
|
'shopping_user_not_found' => 'Erreur : aucun ShoppingUser trouvé',
|
||||||
'account_released' => 'Compte activé',
|
'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_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é.",
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,7 @@ return [
|
||||||
'ship_to_this_email_info' => 'Le lien de commande sera envoyé à votre client à l’adresse e-mail suivante :',
|
'ship_to_this_email_info' => 'Le lien de commande sera envoyé à votre client à l’adresse e-mail suivante :',
|
||||||
'shipping' => 'Livraison',
|
'shipping' => 'Livraison',
|
||||||
'shipping_compensation_product' => 'Produit de compensation 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',
|
'shipping_costs' => 'Frais de livraison',
|
||||||
'shopping_cart' => 'Panier',
|
'shopping_cart' => 'Panier',
|
||||||
'shopping_cart_delete' => 'Supprimer le panier',
|
'shopping_cart_delete' => 'Supprimer le panier',
|
||||||
|
|
|
||||||
56
resources/views/admin/abo/_confirm_add_modal.blade.php
Normal file
56
resources/views/admin/abo/_confirm_add_modal.blade.php
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
<div class="modal fade" id="modal-confirm-add" tabindex="-1" role="dialog" aria-labelledby="modal-confirm-add-label"
|
||||||
|
aria-hidden="true" data-title-add="{{ __('abo.confirm_add_title_normal') }}"
|
||||||
|
data-title-increase="{{ __('abo.confirm_increase_title') }}"
|
||||||
|
data-info-add="{{ __('abo.confirm_add_info') }}"
|
||||||
|
data-info-increase="{{ __('abo.confirm_increase_info') }}"
|
||||||
|
data-per-delivery="{{ __('abo.confirm_per_delivery') }}"
|
||||||
|
data-current-total="{{ $user_abo->getFormattedAmount() }}"
|
||||||
|
data-next-billing-date="{{ $user_abo->next_date ?: __('abo.confirm_next_delivery_unknown') }}">
|
||||||
|
<div class="modal-dialog" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="modal-confirm-add-label"></h5>
|
||||||
|
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="alert alert-info mb-3">
|
||||||
|
<span id="confirm-add-info-text"></span>
|
||||||
|
</div>
|
||||||
|
<div class="font-weight-bold mb-2">{{ __('abo.confirm_article_details') }}</div>
|
||||||
|
<table class="table table-sm mb-0">
|
||||||
|
<tr>
|
||||||
|
<td class="font-weight-bold">{{ __('order.article') }}:</td>
|
||||||
|
<td id="confirm-add-product-name"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="font-weight-bold">{{ __('tables.quantity') }}:</td>
|
||||||
|
<td id="confirm-add-qty-info"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="font-weight-bold">{{ __('abo.confirm_additional_costs') }}:</td>
|
||||||
|
<td id="confirm-add-product-price"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="font-weight-bold">{{ __('abo.confirm_new_total') }}:</td>
|
||||||
|
<td id="confirm-add-new-total"></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p class="small text-muted mb-0 mt-3">
|
||||||
|
{!! __('abo.confirm_legal_notice', [
|
||||||
|
'nextBillingDate' => '<span id="confirm-add-next-billing-date"></span>',
|
||||||
|
'agb' => '<a href="'.url('/agb').'" target="_blank">'.__('abo.confirm_terms_link').'</a>',
|
||||||
|
'withdrawal' => '<a href="'.asset('download/mivita_widerruf_formular.pdf').'" target="_blank">'.__('abo.confirm_withdrawal_link').'</a>',
|
||||||
|
]) !!}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-default"
|
||||||
|
data-dismiss="modal">{{ __('abo.confirm_add_cancel') }}</button>
|
||||||
|
<button type="button" class="btn btn-primary"
|
||||||
|
id="confirm-add-btn">{{ __('abo.confirm_add_ok') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -134,8 +134,10 @@
|
||||||
@php
|
@php
|
||||||
$only_show_products = isset($only_show_products) ? $only_show_products : false;
|
$only_show_products = isset($only_show_products) ? $only_show_products : false;
|
||||||
$add_only_mode = isset($add_only_mode) ? $add_only_mode : 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
|
@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])
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
@php($cartInstance = \App\Services\AboOrderCart::INSTANCE)
|
||||||
@if(isset($error_message) && $error_message)
|
@if(isset($error_message) && $error_message)
|
||||||
<div class="alert alert-danger mt-2" id="insert_show_error_message">{{ $error_message }}</div>
|
<div class="alert alert-danger mt-2" id="insert_show_error_message">{{ $error_message }}</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
@ -65,37 +66,49 @@
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="4"><hr></td>
|
<td colspan="4"><hr></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@if(isset($split_mode) && $split_mode)
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="text-right small"><strong>{{ __('abo.abo_subtotal') }}:</strong></td>
|
||||||
|
<td class="text-right small">{{ formatNumber($summary['abo']['gross'] ?? 0) }} €</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="text-right small no-border-top"><strong>{{ __('abo.abo_next_delivery_total') }}:</strong></td>
|
||||||
|
<td class="text-right small no-border-top">{{ formatNumber($summary['total_with_shipping'] ?? 0) }} €</td>
|
||||||
|
</tr>
|
||||||
|
@else
|
||||||
|
@php($taxFree = Yard::instance($cartInstance)->getUserTaxFree())
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="3" class="text-right small"><strong>{{ __('order.subtotal') }}:</strong></td>
|
<td colspan="3" class="text-right small"><strong>{{ __('order.subtotal') }}:</strong></td>
|
||||||
<td class="text-right small">{{ Yard::instance('shopping')->subtotal() }} €</td>
|
<td class="text-right small">{{ Yard::instance($cartInstance)->subtotal() }} €</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="3" class="text-right small no-border-top"><strong>{{ __('Delivery country') }}:</strong></td>
|
<td colspan="3" class="text-right small no-border-top"><strong>{{ __('Delivery country') }}:</strong></td>
|
||||||
<td class="text-right small no-border-top">{{ Yard::instance('shopping')->getShippingCountryName() }}</td>
|
<td class="text-right small no-border-top">{{ Yard::instance($cartInstance)->getShippingCountryName() }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="3" class="text-right small no-border-top"><strong>{{ __('order.shipping_costs') }}:</strong></td>
|
<td colspan="3" class="text-right small no-border-top"><strong>{{ __('order.shipping_costs') }}:</strong></td>
|
||||||
<td class="text-right small no-border-top">{{ Yard::instance('shopping')->shipping() }} € </td>
|
<td class="text-right small no-border-top">{{ $taxFree ? Yard::instance($cartInstance)->shippingNet() : Yard::instance($cartInstance)->shipping() }} € </td>
|
||||||
</tr>
|
</tr>
|
||||||
@if(Yard::instance('shopping')->getUserTaxFree())
|
@if($taxFree)
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="3" class="text-right small no-border-top"><strong>{{ __('order.sum_net') }}:</strong></td>
|
<td colspan="3" class="text-right small no-border-top"><strong>{{ __('order.sum_net') }}:</strong></td>
|
||||||
<td class="text-right small no-border-top">{{ Yard::instance('shopping')->subtotalWithShipping() }} €</td>
|
<td class="text-right small no-border-top">{{ Yard::instance($cartInstance)->subtotalWithShipping() }} €</td>
|
||||||
</tr>
|
</tr>
|
||||||
@else
|
@else
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="3" class="text-right small no-border-top"><strong>{{ __('order.total_without_VAT') }}:</strong></td>
|
<td colspan="3" class="text-right small no-border-top"><strong>{{ __('order.total_without_VAT') }}:</strong></td>
|
||||||
<td class="text-right small no-border-top">{{ Yard::instance('shopping')->subtotalWithShipping() }} €</td>
|
<td class="text-right small no-border-top">{{ Yard::instance($cartInstance)->subtotalWithShipping() }} €</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="3" class="text-right small no-border-top"><strong>{{ __('order.plus_VAT') }}:</strong></td>
|
<td colspan="3" class="text-right small no-border-top"><strong>{{ __('order.plus_VAT') }}:</strong></td>
|
||||||
<td class="text-right small no-border-top">{{ Yard::instance('shopping')->taxWithShipping() }} €</td>
|
<td class="text-right small no-border-top">{{ Yard::instance($cartInstance)->taxWithShipping() }} €</td>
|
||||||
</tr>
|
</tr>
|
||||||
@endif
|
@endif
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="3" class="text-right"><strong>{{ __('order.total_sum') }}:</strong></td>
|
<td colspan="3" class="text-right"><strong>{{ __('order.total_sum') }}:</strong></td>
|
||||||
<td class="text-right">{{ Yard::instance('shopping')->totalWithShipping() }} €</td>
|
<td class="text-right">{{ Yard::instance($cartInstance)->totalWithShipping() }} €</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@endif
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
50
resources/views/admin/abo/_order_combined_summary.blade.php
Normal file
50
resources/views/admin/abo/_order_combined_summary.blade.php
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
@php
|
||||||
|
$cartInstance = \App\Services\AboOrderCart::INSTANCE;
|
||||||
|
$taxFree = Yard::instance($cartInstance)->getUserTaxFree();
|
||||||
|
@endphp
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<h5 class="font-weight-semibold mb-0">{{ __('abo.combined_summary_hl') }}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body bg-light">
|
||||||
|
<table class="table table-product m-0">
|
||||||
|
<tbody>
|
||||||
|
@if(($summary['one_time']['gross'] ?? 0) > 0)
|
||||||
|
<tr>
|
||||||
|
<td class="small"><strong>{{ __('abo.onetime_subtotal') }}:</strong></td>
|
||||||
|
<td class="text-right small">{{ formatNumber($summary['one_time']['gross']) }} €</td>
|
||||||
|
</tr>
|
||||||
|
@endif
|
||||||
|
<tr>
|
||||||
|
<td class="small"><strong>{{ __('abo.abo_subtotal') }}:</strong></td>
|
||||||
|
<td class="text-right small">{{ formatNumber($summary['abo']['gross'] ?? 0) }} €</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="small no-border-top"><strong>{{ __('Delivery country') }}:</strong></td>
|
||||||
|
<td class="text-right small no-border-top">{{ Yard::instance($cartInstance)->getShippingCountryName() }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="small no-border-top"><strong>{{ __('order.shipping_costs') }}:</strong></td>
|
||||||
|
<td class="text-right small no-border-top">{{ $taxFree ? Yard::instance($cartInstance)->shippingNet() : Yard::instance($cartInstance)->shipping() }} €</td>
|
||||||
|
</tr>
|
||||||
|
@if($taxFree)
|
||||||
|
<tr>
|
||||||
|
<td class="small no-border-top"><strong>{{ __('order.sum_net') }}:</strong></td>
|
||||||
|
<td class="text-right small no-border-top">{{ Yard::instance($cartInstance)->subtotalWithShipping() }} €</td>
|
||||||
|
</tr>
|
||||||
|
@else
|
||||||
|
<tr>
|
||||||
|
<td class="small no-border-top"><strong>{{ __('order.total_without_VAT') }}:</strong></td>
|
||||||
|
<td class="text-right small no-border-top">{{ Yard::instance($cartInstance)->subtotalWithShipping() }} €</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="small no-border-top"><strong>{{ __('order.plus_VAT') }}:</strong></td>
|
||||||
|
<td class="text-right small no-border-top">{{ Yard::instance($cartInstance)->taxWithShipping() }} €</td>
|
||||||
|
</tr>
|
||||||
|
@endif
|
||||||
|
<tr class="bg-white">
|
||||||
|
<td class="pt-2 pb-2"><strong>{{ __('order.total_sum') }}:</strong></td>
|
||||||
|
<td class="text-right font-weight-bold pt-2 pb-2">{{ Yard::instance($cartInstance)->totalWithShipping() }} €</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
23
resources/views/admin/abo/_order_onetime.blade.php
Normal file
23
resources/views/admin/abo/_order_onetime.blade.php
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="font-weight-semibold mb-2">
|
||||||
|
<i class="fa fa-bolt text-warning"></i> {{ __('abo.onetime_card_hl') }}
|
||||||
|
</h5>
|
||||||
|
<div class="alert alert-info">{!! __('abo.onetime_card_info') !!}</div>
|
||||||
|
<div class="alert alert-warning small mb-3">
|
||||||
|
<strong>{{ __('abo.onetime_important_hl') }}</strong>
|
||||||
|
<ul class="mb-0 pl-3">
|
||||||
|
<li>{{ __('abo.onetime_reset_hint') }}</li>
|
||||||
|
<li>{{ __('abo.onetime_coverage_hint') }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" class="btn btn-sm btn-warning btn-block mt-2"
|
||||||
|
data-toggle="modal" data-target="#modals-load-content"
|
||||||
|
data-id="{{ $user_abo->id }}"
|
||||||
|
data-action="abo-add-onetime"
|
||||||
|
data-route="{{ route('modal_load') }}"><i class="fa fa-plus-circle"></i> {{ __('abo.add_onetime_product') }}</button>
|
||||||
|
|
||||||
|
<div id="insert_show_onetime_order" data-onetime-abo-id="{{ $user_abo->id }}" class="mt-2">
|
||||||
|
@include('admin.abo._order_onetime_show', ['user_abo' => $user_abo, 'summary' => $summary])
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
105
resources/views/admin/abo/_order_onetime_show.blade.php
Normal file
105
resources/views/admin/abo/_order_onetime_show.blade.php
Normal file
|
|
@ -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)
|
||||||
|
<div class="alert alert-danger mt-2" id="insert_onetime_error_message">{{ $error_message }}</div>
|
||||||
|
@endif
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-product m-0" style="min-width:550px;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>{{ __('order.article') }}</th>
|
||||||
|
<th>{{ __('tables.quantity') }}</th>
|
||||||
|
<th class="text-right">{{ __('tables.price') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse($one_time_items as $one_time_item)
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
@if($one_time_item->product && count($one_time_item->product->images))
|
||||||
|
<img class="img-fluid img-extra" alt="" src="{{ route('product_image', [$one_time_item->product->images->first()->slug]) }}">
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="min-width-80">
|
||||||
|
<strong>{{ $one_time_item->product?->getLang('name') }}</strong>
|
||||||
|
<span class="badge badge-pill badge-warning"><i class="fa fa-bolt"></i> {{ __('abo.onetime_badge') }}</span>
|
||||||
|
<div class="text-body">
|
||||||
|
<div>{{ __('order.content') }}: {{ $one_time_item->product?->contents }}</div>
|
||||||
|
<div>{{ __('order.art_no') }}: {{ $one_time_item->product?->number }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="options">
|
||||||
|
<a href="#" class="auto-delete-product remove_onetime_from_cart product-tooltip" data-onetime-item-id="{{ $one_time_item->id }}"><i class="fa fa-times"></i> {{ __('order.article_remove') }}</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="no-line-break input-group-min-w">
|
||||||
|
<div class="input-group d-inline-flex w-auto">
|
||||||
|
<span class="input-group-prepend">
|
||||||
|
<button type="button" class="btn btn-secondary icon-btn md-btn-extra onetime-remove-from-basket" data-onetime-item-id="{{ $one_time_item->id }}">-</button>
|
||||||
|
</span>
|
||||||
|
<input type="text" class="form-control text-center input-extra onetime-input-onchange" name="onetime_qty_{{ $one_time_item->id }}" data-onetime-item-id="{{ $one_time_item->id }}" value="{{ $one_time_item->qty }}">
|
||||||
|
<span class="input-group-append">
|
||||||
|
<button type="button" class="btn btn-secondary icon-btn md-btn-extra onetime-add-from-basket" data-onetime-item-id="{{ $one_time_item->id }}">+</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-right font-weight-semibold align-top px-3 py-2" style="width: 100px;">
|
||||||
|
<div class="no-line-break">{{ $one_time_item->getFormattedTotalPrice() }} €</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="text-muted small py-3 text-center">{{ __('abo.onetime_empty') }}</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td colspan="4"><hr></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="text-right small"><strong>{{ __('abo.onetime_subtotal') }}:</strong></td>
|
||||||
|
<td class="text-right small">{{ formatNumber($summary['one_time']['gross'] ?? 0) }} €</td>
|
||||||
|
</tr>
|
||||||
|
@if($oneTimeGross > 0 || $hasOneTimeChanges || $hasConfirmedOneTimeItems)
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="text-right small no-border-top"><strong>{{ __('abo.onetime_next_delivery_total') }}:</strong></td>
|
||||||
|
<td class="text-right small no-border-top">{{ formatNumber($nextDeliveryTotal) }} €</td>
|
||||||
|
</tr>
|
||||||
|
@endif
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@if($oneTimeGross > 0 || $hasOneTimeChanges || $hasConfirmedOneTimeItems)
|
||||||
|
<div class="mt-3" id="onetime_confirmation_block" data-confirmation-state="{{ $oneTimeConfirmationState }}" aria-live="polite">
|
||||||
|
<p class="small text-muted mb-2">
|
||||||
|
{!! __('abo.onetime_legal_notice', [
|
||||||
|
'nextBillingDate' => $user_abo->next_date ?: __('abo.confirm_next_delivery_unknown'),
|
||||||
|
'agb' => '<a href="'.url('/agb').'" target="_blank">'.__('abo.confirm_terms_link').'</a>',
|
||||||
|
'withdrawal' => '<a href="'.asset('download/mivita_widerruf_formular.pdf').'" target="_blank">'.__('abo.confirm_withdrawal_link').'</a>',
|
||||||
|
]) !!}
|
||||||
|
</p>
|
||||||
|
@if($hasOneTimeChanges)
|
||||||
|
<div class="d-flex flex-wrap justify-content-end">
|
||||||
|
<button type="button" class="btn btn-default mr-2 mb-2 onetime-discard-changes">
|
||||||
|
{{ __('abo.onetime_discard_changes') }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-primary mb-2 onetime-confirm-changes">
|
||||||
|
{{ __('abo.onetime_confirm_paid') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@elseif($hasConfirmedOneTimeItems)
|
||||||
|
<div class="alert alert-success small mb-0">
|
||||||
|
<span aria-hidden="true">✓</span> {{ __('abo.onetime_confirm_success') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
@ -51,13 +51,14 @@
|
||||||
'data-add-only-mode' => '0',
|
'data-add-only-mode' => '0',
|
||||||
]) !!}
|
]) !!}
|
||||||
<input type="hidden" name="is_for" value="{{ $user_abo->is_for }}">
|
<input type="hidden" name="is_for" value="{{ $user_abo->is_for }}">
|
||||||
|
@php($cartInstance = \App\Services\AboOrderCart::INSTANCE)
|
||||||
<div class="card mt-3">
|
<div class="card mt-3">
|
||||||
@include('admin.abo._order_abo', ['add_only_mode' => false])
|
@include('admin.abo._order_abo', ['add_only_mode' => false])
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if ($comp_products && Yard::instance('shopping')->getNumComp() > 0)
|
@if ($comp_products && Yard::instance($cartInstance)->getNumComp() > 0)
|
||||||
<div id="holder_html_view_comp_product">
|
<div id="holder_html_view_comp_product">
|
||||||
@include('user.order.comp_product')
|
@include('user.order.comp_product', ['cart_instance' => $cartInstance])
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
|
@ -78,47 +79,7 @@
|
||||||
|
|
||||||
<a href="{{ route('admin_abos') }}" class="btn btn-sm btn-default mt-2 float-right">{{ __('back') }}</a>
|
<a href="{{ route('admin_abos') }}" class="btn btn-sm btn-default mt-2 float-right">{{ __('back') }}</a>
|
||||||
|
|
||||||
<div class="modal fade" id="modal-confirm-add" tabindex="-1" role="dialog" aria-labelledby="modal-confirm-add-label"
|
@include('admin.abo._confirm_add_modal')
|
||||||
aria-hidden="true" data-title-add-only="{{ __('abo.confirm_add_title') }}"
|
|
||||||
data-title-normal="{{ __('abo.confirm_add_title_normal') }}"
|
|
||||||
data-warning-add-only="{{ __('abo.confirm_add_warning') }}"
|
|
||||||
data-warning-normal="{{ __('abo.confirm_add_warning_normal') }}">
|
|
||||||
<div class="modal-dialog" role="document">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title" id="modal-confirm-add-label"></h5>
|
|
||||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
|
||||||
<span aria-hidden="true">×</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="alert alert-warning mb-3">
|
|
||||||
<i class="fa fa-exclamation-triangle"></i> <span id="confirm-add-warning-text"></span>
|
|
||||||
</div>
|
|
||||||
<table class="table table-sm mb-0">
|
|
||||||
<tr>
|
|
||||||
<td class="font-weight-bold">{{ __('order.article') }}:</td>
|
|
||||||
<td id="confirm-add-product-name"></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="font-weight-bold">{{ __('tables.price') }}:</td>
|
|
||||||
<td id="confirm-add-product-price"></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="font-weight-bold">{{ __('tables.quantity') }}:</td>
|
|
||||||
<td id="confirm-add-qty-info"></td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-default"
|
|
||||||
data-dismiss="modal">{{ __('abo.confirm_add_cancel') }}</button>
|
|
||||||
<button type="button" class="btn btn-primary"
|
|
||||||
id="confirm-add-btn">{{ __('abo.confirm_add_ok') }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if ($user_abo->status === 3 && $user_abo->active)
|
@if ($user_abo->status === 3 && $user_abo->active)
|
||||||
<div class="modal fade" id="modal-retry-abo-payment" tabindex="-1" role="dialog" aria-labelledby="modal-retry-abo-payment-label" aria-hidden="true">
|
<div class="modal fade" id="modal-retry-abo-payment" tabindex="-1" role="dialog" aria-labelledby="modal-retry-abo-payment-label" aria-hidden="true">
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,12 @@
|
||||||
{{ Form::text('settings[abo-min-duration][val]', \App\Models\Setting::getContentBySlug('abo-min-duration'), array('class'=>'form-control')) }}
|
{{ Form::text('settings[abo-min-duration][val]', \App\Models\Setting::getContentBySlug('abo-min-duration'), array('class'=>'form-control')) }}
|
||||||
{{ Form::hidden('settings[abo-min-duration][type]', 'int') }}
|
{{ Form::hidden('settings[abo-min-duration][type]', 'int') }}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group col-sm-6">
|
||||||
|
<label class="form-label">Zeitfenster für einmalige Produkte vor Ausführung (Tage)*</label>
|
||||||
|
{{ 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') }}
|
||||||
|
<small class="form-text text-muted">Anzahl Tage vor der Abo-Ausführung, in denen einmalig Produkte aus dem normalen Sortiment hinzugefügt werden können (Standard: 4).</small>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" name="action" value="save_prepayment" class="btn btn-primary btn-sm mb-2"><i class="ion ion-ios-save"></i> speichern</button>
|
<button type="submit" name="action" value="save_prepayment" class="btn btn-primary btn-sm mb-2"><i class="ion ion-ios-save"></i> speichern</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,20 @@
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
|
|
||||||
<h4 class="font-weight-bold py-2 mb-2">
|
<h4 class="font-weight-bold py-2 mb-2">
|
||||||
{{ __('navigation.my_abo') }}
|
{{ __('navigation.my_abo') }}
|
||||||
<span class="text-muted">{{ '#'.$user_abo->payone_userid }}</span>
|
<span class="text-muted">{{ '#' . $user_abo->payone_userid }}</span>
|
||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
@if(Session::has('alert-error'))
|
@if (Session::has('alert-error'))
|
||||||
<div class="col-sm-12">
|
<div class="col-sm-12">
|
||||||
<div class="alert alert-danger p-2 mt-2">
|
<div class="alert alert-danger p-2 mt-2">
|
||||||
<ul>
|
<ul>
|
||||||
<li>{{ Session::get('alert-error') }}</li>
|
<li>{{ Session::get('alert-error') }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@include('admin.abo._detail')
|
@include('admin.abo._detail')
|
||||||
|
|
@ -28,15 +28,59 @@
|
||||||
@php
|
@php
|
||||||
$addOnlyMode = App\Services\AboHelper::isAddOnlyMode($user_abo, $view);
|
$addOnlyMode = App\Services\AboHelper::isAddOnlyMode($user_abo, $view);
|
||||||
@endphp
|
@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']) !!}
|
{!! Form::open([
|
||||||
<input type="hidden" name="is_for" value="{{ $user_abo->is_for }}">
|
'action' => route('user_abos_update', [$view, $user_abo->id]),
|
||||||
<div class="card mt-3">
|
'class' => 'form-horizontal',
|
||||||
@include('admin.abo._order_abo', ['add_only_mode' => $addOnlyMode])
|
'id' => 'cart-order-form',
|
||||||
</div>
|
'data-add-only-mode' => $addOnlyMode ? '1' : '0',
|
||||||
|
]) !!}
|
||||||
|
<input type="hidden" name="is_for" value="{{ $user_abo->is_for }}">
|
||||||
|
@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)
|
||||||
|
<div class="row mt-3">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card" id="onetime_order_card"
|
||||||
|
data-onetime-route="{{ route('user_abos_onetime', [$view, $user_abo->id]) }}">
|
||||||
|
@include('admin.abo._order_onetime', ['summary' => $summary])
|
||||||
|
</div>
|
||||||
|
<div class="card mt-3 mb-3">
|
||||||
|
@include('admin.abo._order_abo', [
|
||||||
|
'add_only_mode' => $addOnlyMode,
|
||||||
|
'split_mode' => true,
|
||||||
|
'summary' => $summary,
|
||||||
|
])
|
||||||
|
</div>
|
||||||
|
<div id="holder_html_view_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
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4" id="combined_summary_col">
|
||||||
|
<div class="card js-abo-sticky" id="combined_summary_card">
|
||||||
|
@include('admin.abo._order_combined_summary', ['summary' => $summary])
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="card mt-3">
|
||||||
|
@include('admin.abo._order_abo', ['add_only_mode' => $addOnlyMode])
|
||||||
|
</div>
|
||||||
|
|
||||||
@if($comp_products && Yard::instance('shopping')->getNumComp() > 0)
|
|
||||||
<div id="holder_html_view_comp_product">
|
<div id="holder_html_view_comp_product">
|
||||||
@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
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
|
@ -47,54 +91,18 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<a href="{{route('portal.my_subscriptions')}}" class="btn btn-sm btn-default float-right">{{ __('abo.back') }}</a>
|
<a href="{{ route('portal.my_subscriptions') }}" class="btn btn-sm btn-default float-right">{{ __('abo.back') }}</a>
|
||||||
|
|
||||||
<div class="modal fade" id="modal-confirm-add" tabindex="-1" role="dialog" aria-labelledby="modal-confirm-add-label" aria-hidden="true"
|
@include('admin.abo._confirm_add_modal')
|
||||||
data-title-add-only="{{ __('abo.confirm_add_title') }}"
|
|
||||||
data-title-normal="{{ __('abo.confirm_add_title_normal') }}"
|
|
||||||
data-warning-add-only="{{ __('abo.confirm_add_warning') }}"
|
|
||||||
data-warning-normal="{{ __('abo.confirm_add_warning_normal') }}">
|
|
||||||
<div class="modal-dialog" role="document">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title" id="modal-confirm-add-label"></h5>
|
|
||||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
|
||||||
<span aria-hidden="true">×</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="alert alert-warning mb-3">
|
|
||||||
<i class="fa fa-exclamation-triangle"></i> <span id="confirm-add-warning-text"></span>
|
|
||||||
</div>
|
|
||||||
<table class="table table-sm mb-0">
|
|
||||||
<tr>
|
|
||||||
<td class="font-weight-bold">{{ __('order.article') }}:</td>
|
|
||||||
<td id="confirm-add-product-name"></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="font-weight-bold">{{ __('tables.price') }}:</td>
|
|
||||||
<td id="confirm-add-product-price"></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="font-weight-bold">{{ __('tables.quantity') }}:</td>
|
|
||||||
<td id="confirm-add-qty-info"></td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-default" data-dismiss="modal">{{ __('abo.confirm_add_cancel') }}</button>
|
|
||||||
<button type="button" class="btn btn-primary" id="confirm-add-btn">{{ __('abo.confirm_add_ok') }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@endsection
|
@endsection
|
||||||
|
|
||||||
@section('scripts')
|
@section('scripts')
|
||||||
<script src="{{ asset('/js/iq-modal-cart.js') }}?v=1{{ get_file_last_time('/js/iq-modal-cart.js') }}"></script>
|
<script src="{{ asset('/js/iq-modal-cart.js') }}?v=1{{ get_file_last_time('/js/iq-modal-cart.js') }}"></script>
|
||||||
|
<script src="{{ asset('/js/iq-abo-onetime.js') }}?v=1{{ get_file_last_time('/js/iq-abo-onetime.js') }}"></script>
|
||||||
<script type="application/javascript">
|
<script type="application/javascript">
|
||||||
var iqModalCart = IqModalCart.init();
|
var iqModalCart = IqModalCart.init();
|
||||||
|
var iqAboOneTime = IqAboOneTime.init();
|
||||||
$( document ).ready(function() {
|
$( document ).ready(function() {
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,13 +53,52 @@
|
||||||
'data-add-only-mode' => $addOnlyMode ? '1' : '0',
|
'data-add-only-mode' => $addOnlyMode ? '1' : '0',
|
||||||
]) !!}
|
]) !!}
|
||||||
<input type="hidden" name="is_for" value="{{ $user_abo->is_for }}">
|
<input type="hidden" name="is_for" value="{{ $user_abo->is_for }}">
|
||||||
<div class="card mt-3">
|
@php
|
||||||
@include('admin.abo._order_abo', ['add_only_mode' => $addOnlyMode])
|
$oneTimeWindowOpen = isset($one_time_window_open) ? $one_time_window_open : false;
|
||||||
</div>
|
$summary = isset($summary) ? $summary : null;
|
||||||
|
$cartInstance = \App\Services\AboOrderCart::INSTANCE;
|
||||||
|
@endphp
|
||||||
|
@if ($oneTimeWindowOpen)
|
||||||
|
<div class="row mt-3">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card" id="onetime_order_card"
|
||||||
|
data-onetime-route="{{ route('user_abos_onetime', [$view, $user_abo->id]) }}">
|
||||||
|
@include('admin.abo._order_onetime', ['summary' => $summary])
|
||||||
|
</div>
|
||||||
|
<div class="card mt-3">
|
||||||
|
@include('admin.abo._order_abo', [
|
||||||
|
'add_only_mode' => $addOnlyMode,
|
||||||
|
'split_mode' => true,
|
||||||
|
'summary' => $summary,
|
||||||
|
])
|
||||||
|
</div>
|
||||||
|
<div id="holder_html_view_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
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4" id="combined_summary_col">
|
||||||
|
<div class="card js-abo-sticky" id="combined_summary_card">
|
||||||
|
@include('admin.abo._order_combined_summary', ['summary' => $summary])
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="card mt-3">
|
||||||
|
@include('admin.abo._order_abo', ['add_only_mode' => $addOnlyMode])
|
||||||
|
</div>
|
||||||
|
|
||||||
@if ($comp_products && Yard::instance('shopping')->getNumComp() > 0)
|
|
||||||
<div id="holder_html_view_comp_product">
|
<div id="holder_html_view_comp_product">
|
||||||
@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
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
|
@ -71,53 +110,15 @@
|
||||||
|
|
||||||
<a href="{{ route('user_abos', [$view]) }}" class="btn btn-sm btn-default float-right">{{ __('back') }}</a>
|
<a href="{{ route('user_abos', [$view]) }}" class="btn btn-sm btn-default float-right">{{ __('back') }}</a>
|
||||||
|
|
||||||
<div class="modal fade" id="modal-confirm-add" tabindex="-1" role="dialog" aria-labelledby="modal-confirm-add-label"
|
@include('admin.abo._confirm_add_modal')
|
||||||
aria-hidden="true" data-title-add-only="{{ __('abo.confirm_add_title') }}"
|
|
||||||
data-title-normal="{{ __('abo.confirm_add_title_normal') }}"
|
|
||||||
data-warning-add-only="{{ __('abo.confirm_add_warning') }}"
|
|
||||||
data-warning-normal="{{ __('abo.confirm_add_warning_normal') }}">
|
|
||||||
<div class="modal-dialog" role="document">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title" id="modal-confirm-add-label"></h5>
|
|
||||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
|
||||||
<span aria-hidden="true">×</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="alert alert-warning mb-3">
|
|
||||||
<i class="fa fa-exclamation-triangle"></i> <span id="confirm-add-warning-text"></span>
|
|
||||||
</div>
|
|
||||||
<table class="table table-sm mb-0">
|
|
||||||
<tr>
|
|
||||||
<td class="font-weight-bold">{{ __('order.article') }}:</td>
|
|
||||||
<td id="confirm-add-product-name"></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="font-weight-bold">{{ __('tables.price') }}:</td>
|
|
||||||
<td id="confirm-add-product-price"></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="font-weight-bold">{{ __('tables.quantity') }}:</td>
|
|
||||||
<td id="confirm-add-qty-info"></td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-default"
|
|
||||||
data-dismiss="modal">{{ __('abo.confirm_add_cancel') }}</button>
|
|
||||||
<button type="button" class="btn btn-primary"
|
|
||||||
id="confirm-add-btn">{{ __('abo.confirm_add_ok') }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@endsection
|
@endsection
|
||||||
|
|
||||||
@section('scripts')
|
@section('scripts')
|
||||||
<script src="{{ asset('/js/iq-modal-cart.js') }}?v=1{{ get_file_last_time('/js/iq-modal-cart.js') }}"></script>
|
<script src="{{ asset('/js/iq-modal-cart.js') }}?v=1{{ get_file_last_time('/js/iq-modal-cart.js') }}"></script>
|
||||||
|
<script src="{{ asset('/js/iq-abo-onetime.js') }}?v=1{{ get_file_last_time('/js/iq-abo-onetime.js') }}"></script>
|
||||||
<script type="application/javascript">
|
<script type="application/javascript">
|
||||||
var iqModalCart = IqModalCart.init();
|
var iqModalCart = IqModalCart.init();
|
||||||
|
var iqAboOneTime = IqAboOneTime.init();
|
||||||
$( document ).ready(function() {
|
$( document ).ready(function() {
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">
|
||||||
|
<i class="fa fa-bolt text-warning"></i> {{ __('abo.add_onetime_product') }}
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="close" data-dismiss="modal" aria-label="Close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body px-0">
|
||||||
|
<div class="card-datatable pt-0 table-responsive">
|
||||||
|
<table id="datatable-abo-onetime" class="table table-striped table-bordered" data-user_abo-id="{{ $user_abo->id }}">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th></th>
|
||||||
|
<th>{{ __('tables.image') }}</th>
|
||||||
|
<th>{{ __('tables.products') }}</th>
|
||||||
|
<th>{{ __('tables.article_no') }}</th>
|
||||||
|
<th><span class="no-line-break">{{ __('tables.price') }}</span> {{ __('tables.net') }}</th>
|
||||||
|
<th><span class="no-line-break">{{ __('tables.price') }}</span> {{ __('tables.gross') }}</th>
|
||||||
|
<th>{{ __('tables.points') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-default" data-dismiss="modal">{{ __('close') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
$(document).ready(function () {
|
||||||
|
var oTable = $('#datatable-abo-onetime').DataTable({
|
||||||
|
"processing": true,
|
||||||
|
"serverSide": true,
|
||||||
|
"ajax": '{!! route('user_abo_onetime_datatable', [$user_abo->id]) !!}',
|
||||||
|
"order": [[2, "asc"]],
|
||||||
|
"columns": [
|
||||||
|
{ data: 'add_card', name: 'add_card', searchable: false, orderable: false },
|
||||||
|
{ data: 'picture', name: 'picture', searchable: false, width: 35 },
|
||||||
|
{ data: 'name', name: 'name' },
|
||||||
|
{ data: 'number', name: 'number' },
|
||||||
|
{ data: 'price_net', name: 'price_net', searchable: false, orderable: false },
|
||||||
|
{ data: 'price_gross', name: 'price_gross', searchable: false, orderable: false },
|
||||||
|
{ data: 'points', name: 'points', searchable: false },
|
||||||
|
],
|
||||||
|
"bLengthChange": false,
|
||||||
|
"iDisplayLength": 1000,
|
||||||
|
"paging": false,
|
||||||
|
"language": {
|
||||||
|
"url": "/js/datatables-{{ \App::getLocale() }}.json"
|
||||||
|
},
|
||||||
|
drawCallback: function (settings) {
|
||||||
|
iqAboOneTime.reInitModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
iqAboOneTime.setDatabase('#datatable-abo-onetime');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
@ -1,39 +1,49 @@
|
||||||
|
@php($cartInstance = $cart_instance ?? 'shopping')
|
||||||
<div class="card mt-4">
|
@php($baseCompCount = $base_comp_count ?? Yard::instance($cartInstance)->getNumComp())
|
||||||
|
<div class="card mt-3 mb-3">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<input type="hidden" name="count_comp_products" value="{{Yard::instance('shopping')->getNumComp()}}">
|
<input type="hidden" name="count_comp_products" value="{{ Yard::instance($cartInstance)->getNumComp() }}">
|
||||||
@for($i = 1; $i <= Yard::instance('shopping')->getNumComp(); $i++)
|
@for ($i = 1; $i <= Yard::instance($cartInstance)->getNumComp(); $i++)
|
||||||
@if(Yard::instance('shopping')->getNumComp() > 1)
|
@if (Yard::instance($cartInstance)->getNumComp() > 1)
|
||||||
<h5 class="border-bottom pb-2">{{$i}}. {{ __('order.shipping_compensation_product') }} </h5>
|
<h5 class="border-bottom pb-2">{{ $i }}. {{ __('order.shipping_compensation_product') }} </h5>
|
||||||
@else
|
@else
|
||||||
<h5 class="border-bottom pb-2">{{ __('order.shipping_compensation_product') }} </h5>
|
<h5 class="border-bottom pb-2">{{ __('order.shipping_compensation_product') }} </h5>
|
||||||
@endif
|
@endif
|
||||||
|
@if ($i > $baseCompCount)
|
||||||
|
<div class="alert alert-warning small">
|
||||||
|
<i class="fa fa-info-circle"></i> {{ __('order.comp_required_by_additional_products') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
<div class="row no-gutters row-bordered">
|
<div class="row no-gutters row-bordered">
|
||||||
@php($counter = 1)
|
@php($counter = 1)
|
||||||
@php($checked_id = Yard::instance('shopping')->getCompProductBy($i))
|
@php($checked_id = Yard::instance($cartInstance)->getCompProductBy($i))
|
||||||
@foreach($comp_products as $comp_product)
|
@foreach ($comp_products as $comp_product)
|
||||||
<div class="media col-md-6 col-lg-4 p-4">
|
<div class="media col-md-6 col-lg-4 p-4">
|
||||||
<div class="d-block ui-w-80 ui-bordered mr-3">
|
<div class="d-block ui-w-80 ui-bordered mr-3">
|
||||||
@if(count($comp_product->images))
|
@if (count($comp_product->images))
|
||||||
<img src="{{ route('product_image', [$comp_product->images->first()->slug]) }}" class="img-fluid" alt="">
|
<img src="{{ route('product_image', [$comp_product->images->first()->slug]) }}"
|
||||||
|
class="img-fluid" alt="">
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
<div class="media-body">
|
<div class="media-body">
|
||||||
<label class="switcher switcher-secondary">
|
<label class="switcher switcher-secondary">
|
||||||
<input type="radio" class="switcher-input" value="{{$comp_product->id}}" data-comp_num="{{$i}}" name="switchers-comp-product[{{$i}}]" @if($checked_id == $comp_product->id) checked @endif required>
|
<input type="radio" class="switcher-input" value="{{ $comp_product->id }}"
|
||||||
|
data-comp_num="{{ $i }}"
|
||||||
|
name="switchers-comp-product[{{ $i }}]"
|
||||||
|
@if ($checked_id == $comp_product->id) checked @endif required>
|
||||||
<span class="switcher-indicator">
|
<span class="switcher-indicator">
|
||||||
<span class="switcher-yes"></span>
|
<span class="switcher-yes"></span>
|
||||||
<span class="switcher-no"></span>
|
<span class="switcher-no"></span>
|
||||||
</span>
|
</span>
|
||||||
<span class="switcher-label"></span>
|
<span class="switcher-label"></span>
|
||||||
</label>
|
</label>
|
||||||
<div class="text-body mt-2"><strong>{{ $comp_product->getLang('name') }}</strong></div>
|
<div class="text-body mt-2"><strong>{{ $comp_product->getLang('name') }}</strong></div>
|
||||||
<div class="">{{ __('order.art_no') }}: {{ $comp_product->number }}</div>
|
<div class="">{{ __('order.art_no') }}: {{ $comp_product->number }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@php($counter++)
|
@php($counter++)
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@endfor
|
@endfor
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -87,8 +87,44 @@
|
||||||
font-size: 85%;
|
font-size: 85%;
|
||||||
font-weight: 400;
|
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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
<div id="cartContent">
|
<div id="cartContent">
|
||||||
|
@if (isset($error_message) && $error_message)
|
||||||
|
<div class="alert alert-danger mt-2" id="insert_show_error_message">{{ $error_message }}</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
<div class="yard-items-head d-none d-sm-block">
|
<div class="yard-items-head d-none d-sm-block">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
|
@ -176,10 +212,27 @@
|
||||||
<span class="text-right product-tooltip" data-toggle="tooltip"
|
<span class="text-right product-tooltip" data-toggle="tooltip"
|
||||||
title="{{ __('order.compensation_product') }}">1 x</span>
|
title="{{ __('order.compensation_product') }}">1 x</span>
|
||||||
@else
|
@else
|
||||||
<input type="number" class="form-control text-center cart-input-event-onchange"
|
<div class="no-line-break input-group-min-w d-inline-block">
|
||||||
data-row-id="{{ $row->rowId }}" data-product-id="{{ $product->id }}"
|
<div class="input-group d-inline-flex w-auto">
|
||||||
value="{{ $row->qty }}" name="quantity[{{ $row->rowId }}]"
|
<span class="input-group-prepend">
|
||||||
maxlength="3" max="999" min="1">
|
<button type="button"
|
||||||
|
class="btn btn-secondary icon-btn md-btn-extra cart-remove-event"
|
||||||
|
data-row-id="{{ $row->rowId }}"
|
||||||
|
data-product-id="{{ $product->id }}">-</button>
|
||||||
|
</span>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control text-center input-extra cart-input-event-onchange"
|
||||||
|
data-row-id="{{ $row->rowId }}"
|
||||||
|
data-product-id="{{ $product->id }}" value="{{ $row->qty }}"
|
||||||
|
name="quantity[{{ $row->rowId }}]">
|
||||||
|
<span class="input-group-append">
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-secondary icon-btn md-btn-extra cart-add-event"
|
||||||
|
data-row-id="{{ $row->rowId }}"
|
||||||
|
data-product-id="{{ $product->id }}">+</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
<div class="price-total text-right">
|
<div class="price-total text-right">
|
||||||
|
|
|
||||||
|
|
@ -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/{view}', 'User\AboController@index')->name('user_abos');
|
||||||
Route::get('/user/abos/detail/{view}/{id}', 'User\AboController@detail')->name('user_abos_detail');
|
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/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/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 to show team subscriptions (Abos)
|
||||||
Route::get('/user/abos/team/show', 'User\TeamController@showAbos')->name('user_abos_team_show');
|
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');
|
Route::get('/user/abos/team/detail/{id}', 'User\TeamController@detailAbo')->name('user_abos_team_detail');
|
||||||
|
|
|
||||||
|
|
@ -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::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::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/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/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');
|
Route::post('portal/modal/load', [AboController::class, 'modalLoad'])->name('modal_load');
|
||||||
// Routen-Aliase für shared Views (admin.abo._detail, admin.abo._executions)
|
// Routen-Aliase für shared Views (admin.abo._detail, admin.abo._executions)
|
||||||
Route::get('portal/my-data/edit/{id?}', function () {
|
Route::get('portal/my-data/edit/{id?}', function () {
|
||||||
|
|
|
||||||
180
tests/Feature/AboOneTimeServiceTest.php
Normal file
180
tests/Feature/AboOneTimeServiceTest.php
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Requests\Abo\AboOneTimeItemRequest;
|
||||||
|
use App\Models\UserAboOneTimeItem;
|
||||||
|
use App\Services\AboOneTimeService;
|
||||||
|
use App\Services\AboOrderCart;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(Tests\TestCase::class, RefreshDatabase::class);
|
||||||
|
|
||||||
|
describe('AboOneTimeService', function () {
|
||||||
|
beforeEach(function () {
|
||||||
|
makeShopEnv();
|
||||||
|
$this->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');
|
||||||
|
});
|
||||||
|
});
|
||||||
201
tests/Feature/AboOneTimeViewTest.php
Normal file
201
tests/Feature/AboOneTimeViewTest.php
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\UserAboOneTimeItem;
|
||||||
|
use App\Services\AboOrderCart;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(Tests\TestCase::class, RefreshDatabase::class);
|
||||||
|
|
||||||
|
describe('Abo Einmal-Produkte Views', function () {
|
||||||
|
beforeEach(fn () => 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' => '<a href="'.url('/agb').'" target="_blank">'.__('abo.confirm_terms_link').'</a>',
|
||||||
|
'withdrawal' => '<a href="'.asset('download/mivita_widerruf_formular.pdf').'" target="_blank">'.__('abo.confirm_withdrawal_link').'</a>',
|
||||||
|
]))
|
||||||
|
->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' => '<span id="confirm-add-next-billing-date"></span>',
|
||||||
|
'agb' => '<a href="'.url('/agb').'" target="_blank">'.__('abo.confirm_terms_link').'</a>',
|
||||||
|
'withdrawal' => '<a href="'.asset('download/mivita_widerruf_formular.pdf').'" target="_blank">'.__('abo.confirm_withdrawal_link').'</a>',
|
||||||
|
]))
|
||||||
|
->and($html)->toContain(__('abo.confirm_add_ok'));
|
||||||
|
});
|
||||||
|
});
|
||||||
120
tests/Feature/AboOneTimeWindowTest.php
Normal file
120
tests/Feature/AboOneTimeWindowTest.php
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Models\ShoppingOrderItem;
|
||||||
|
use App\Models\UserAbo;
|
||||||
|
use App\Models\UserAboOneTimeItem;
|
||||||
|
use App\Services\AboHelper;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(Tests\TestCase::class, RefreshDatabase::class);
|
||||||
|
|
||||||
|
afterEach(function () {
|
||||||
|
Carbon::setTestNow();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getOneTimeWindowDays', function () {
|
||||||
|
it('liefert den Standardwert, wenn kein Setting gesetzt ist', function () {
|
||||||
|
expect(AboHelper::getOneTimeWindowDays())->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();
|
||||||
|
});
|
||||||
|
});
|
||||||
232
tests/Feature/CartMaxWeightTest.php
Normal file
232
tests/Feature/CartMaxWeightTest.php
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Country;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Models\Shipping;
|
||||||
|
use App\Models\ShippingCountry;
|
||||||
|
use App\Models\ShippingPrice;
|
||||||
|
use App\Models\UserAboItem;
|
||||||
|
use App\Models\UserAboOneTimeItem;
|
||||||
|
use App\Services\AboOneTimeService;
|
||||||
|
use App\Services\AboOrderCart;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
uses(Tests\TestCase::class, RefreshDatabase::class);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Versandland mit zwei Gewichtsstufen: 1–1000 g (4,90 €) und 1001–2000 g (9,90 €).
|
||||||
|
* Maximalgewicht = 2000 g.
|
||||||
|
*/
|
||||||
|
function makeShopEnvWithWeightTiers(): 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' => 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,7 +1,72 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Country;
|
||||||
|
use App\Models\Product;
|
||||||
|
use App\Models\Shipping;
|
||||||
|
use App\Models\ShippingCountry;
|
||||||
|
use App\Models\UserAbo;
|
||||||
|
use App\User;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
uses(Tests\TestCase::class)->in('Feature/Incentive');
|
uses(Tests\TestCase::class)->in('Feature/Incentive');
|
||||||
uses(Tests\TestCase::class)->in('Feature/Sys');
|
uses(Tests\TestCase::class)->in('Feature/Sys');
|
||||||
uses(Tests\TestCase::class)->in('Unit/Incentive');
|
uses(Tests\TestCase::class)->in('Unit/Incentive');
|
||||||
uses(Tests\TestCase::class)->in('Unit/Dhl');
|
uses(Tests\TestCase::class)->in('Unit/Dhl');
|
||||||
uses(Tests\TestCase::class)->in('Feature/PaymentDashboard');
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue