Abo: Einmalprodukte bereits bei der Abo-Anlage + es/fr-Sprachsync
Einmalprodukte (Normalsortiment, show_on 3) können jetzt schon beim Abo-Abschluss hinzugefügt werden – im Portal-Wizard (Abschnitt im Upgrade-Schritt) und in allen drei Sales-Center-Abo-Strecken (eigene Produktliste). Variante A: Zeilen laufen als Cart-Option abo_addon mit durch den Checkout, werden sofort bezahlt und mit der Erstlieferung verschickt (Rechnung/Lieferschein gruppieren automatisch). - Checkout persistiert is_abo_addon (CheckoutRepository, PaymentHelper) - createAboItems/ensureUserAboItemsFromLatestOrder überspringen Addons (sonst würden Einmalprodukte dauerhaft wiederkehren); amount ohne Addon-Anteil; aboHasBaseProduct ignoriert Addon-Zeilen - CreateAboReplacementOrder propagiert is_abo_addon in Ersatzbestellungen - iq-shopping-cart.js: zweite Datatable + abo_addon-Flag-Weitergabe - i18n: neue abo.onetime_create_*-Keys (de/en/es/fr); zusätzlich es/fr komplett synchronisiert (abo-Kartenblock, navigation, payment, weborder inkl. Entfernung PAYONE-Altlasten, withdrawal.php neu) - Tests: AboCreateOneTimeItemsTest, AboCreateOneTimeCartTest, AboPortalCreateOneTimeTest (17 neue, 122 Tests gesamt grün) Plan: dev/2026-07-21-abo-einmalprodukte-anlage/ Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
1e7ae92d0c
commit
cdb121fde0
32 changed files with 1479 additions and 52 deletions
|
|
@ -18,6 +18,7 @@ use App\Services\AboOneTimeService;
|
|||
use App\Services\AboOrderCart;
|
||||
use App\Services\AboPaymentMethodService;
|
||||
use App\Services\AboRecoveryCheckoutService;
|
||||
use App\Services\ProductOrderContext;
|
||||
use App\Services\Shop;
|
||||
use App\Services\UserService;
|
||||
use App\Services\Util;
|
||||
|
|
@ -684,6 +685,10 @@ class AboController extends Controller
|
|||
->whereJsonContains('show_on', ['13'])
|
||||
->orderBy('pos', 'ASC')
|
||||
->get(),
|
||||
'onetime_products' => Product::where('active', true)
|
||||
->whereJsonContains('show_on', ProductOrderContext::oneTimeShowOnIds())
|
||||
->orderBy('pos', 'ASC')
|
||||
->get(),
|
||||
'step' => 0,
|
||||
];
|
||||
if (Request::get('action') == 'back') {
|
||||
|
|
@ -721,6 +726,7 @@ class AboController extends Controller
|
|||
UserService::setInstance($this->instance);
|
||||
UserService::initCustomerYard($shopping_user, 'abo-ot-customer');
|
||||
$this->upgradeProductToCart();
|
||||
$this->oneTimeProductsToCart();
|
||||
$data['step'] = 4;
|
||||
break;
|
||||
case 5:
|
||||
|
|
@ -814,11 +820,83 @@ class AboController extends Controller
|
|||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Übernimmt die im Upgrade-Schritt gewählten Einmalprodukte (Normalsortiment) als
|
||||
* getrennte Cart-Zeilen mit Option `abo_addon = true`: nur erste Lieferung, wird beim
|
||||
* Checkout als is_abo_addon persistiert und nicht Teil des wiederkehrenden Abos.
|
||||
*/
|
||||
private function oneTimeProductsToCart(): void
|
||||
{
|
||||
$data = Request::all();
|
||||
|
||||
if (! isset($data['onetime_product_qty']) || ! is_array($data['onetime_product_qty'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($data['onetime_product_qty'] as $product_id => $quantity) {
|
||||
$product = Product::find($product_id);
|
||||
|
||||
if (! $product || ! ProductOrderContext::productMatchesShowOn($product, ProductOrderContext::oneTimeShowOnIds())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->addOneTimeProductToCart($product, (int) $quantity);
|
||||
}
|
||||
}
|
||||
|
||||
private function addOneTimeProductToCart(Product $product, int $quantity): void
|
||||
{
|
||||
$cartItems = $this->yard->search(function ($item) use ($product) {
|
||||
return $item->id === $product->id && ($item->options->abo_addon ?? false);
|
||||
});
|
||||
|
||||
if ($quantity <= 0) {
|
||||
foreach ($cartItems as $item) {
|
||||
$this->yard->remove($item->rowId);
|
||||
}
|
||||
$this->yard->reCalculateShippingPrice();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($cartItems->count() > 0) {
|
||||
$this->yard->update($cartItems->first()->rowId, $quantity);
|
||||
} else {
|
||||
$image = $product->images->first()->slug ?? '';
|
||||
$price = $product->getPriceWith(
|
||||
$this->yard->getUserTaxFree(),
|
||||
false,
|
||||
$this->yard->getUserCountry()
|
||||
);
|
||||
|
||||
$this->yard->add(
|
||||
$product->id,
|
||||
$product->getLang('name'),
|
||||
$quantity,
|
||||
$price,
|
||||
false,
|
||||
false,
|
||||
[
|
||||
'image' => $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,
|
||||
'abo_addon' => true,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$this->yard->reCalculateShippingPrice();
|
||||
}
|
||||
|
||||
private function addProductToCart($product, $quantity)
|
||||
{
|
||||
// Suche nach dem Produkt im Warenkorb
|
||||
// Suche nach dem Produkt im Warenkorb (Einmal-Zeilen haben eine eigene rowId und bleiben außen vor)
|
||||
$cartItems = $this->yard->search(function ($item) use ($product) {
|
||||
return $item->id === $product->id;
|
||||
return $item->id === $product->id && ! ($item->options->abo_addon ?? false);
|
||||
});
|
||||
|
||||
// Wenn die Menge 0 ist, entferne das Produkt
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ use App\Services\UserService;
|
|||
use App\Services\Util;
|
||||
use App\User;
|
||||
use Auth;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Request;
|
||||
|
|
@ -651,9 +652,10 @@ class OrderController extends Controller
|
|||
{
|
||||
$isAbo = Request::get('is_abo');
|
||||
$shippingIsFor = Request::get('shipping_is_for');
|
||||
$isAddon = (bool) $isAbo && filter_var(Request::get('abo_addon'), FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
if ($shippingIsFor === 'me' || $shippingIsFor === 'abo-me') {
|
||||
$show_on_ids = $isAbo ? ['12', '13'] : ['2'];
|
||||
$show_on_ids = $isAddon ? ProductOrderContext::oneTimeShowOnIds() : ($isAbo ? ['12', '13'] : ['2']);
|
||||
|
||||
$query = Product::with('product_buyings')
|
||||
->select('products.*')
|
||||
|
|
@ -671,7 +673,7 @@ class OrderController extends Controller
|
|||
[$show_on_ids[0], isset($show_on_ids[1]) ? $show_on_ids[1] : $show_on_ids[0]]
|
||||
);
|
||||
} else {
|
||||
$show_on_ids = $isAbo ? ['12', '13'] : ['3'];
|
||||
$show_on_ids = $isAddon ? ProductOrderContext::oneTimeShowOnIds() : ($isAbo ? ['12', '13'] : ['3']);
|
||||
|
||||
$query = Product::select('products.*')
|
||||
->where('active', true)
|
||||
|
|
@ -690,8 +692,18 @@ class OrderController extends Controller
|
|||
}
|
||||
|
||||
return \DataTables::eloquent($query)
|
||||
->addColumn('product', function (Product $product) {
|
||||
$cartItem = Yard::instance('shopping')->getCartItemByProduct($product->id);
|
||||
->addColumn('product', function (Product $product) use ($isAddon) {
|
||||
if ($isAddon) {
|
||||
$cartItem = Yard::instance('shopping')->search(function ($item) use ($product) {
|
||||
return (int) $item->id === (int) $product->id && ($item->options->abo_addon ?? false);
|
||||
})->first();
|
||||
$inputName = 'onetime_qty_'.$product->id;
|
||||
$addonAttr = ' data-abo-addon="1"';
|
||||
} else {
|
||||
$cartItem = Yard::instance('shopping')->getCartItemByProduct($product->id);
|
||||
$inputName = 'product_qty_'.$product->id;
|
||||
$addonAttr = '';
|
||||
}
|
||||
$qty = isset($cartItem->qty) ? $cartItem->qty : 0;
|
||||
$rowId = isset($cartItem->rowId) ? $cartItem->rowId : '';
|
||||
|
||||
|
|
@ -699,11 +711,11 @@ class OrderController extends Controller
|
|||
<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 remove-product-basket" data-row-id="'.$rowId.'" data-product-id="'.$product->id.'">-</button>
|
||||
<button type="button" class="btn btn-secondary icon-btn md-btn-extra remove-product-basket" data-row-id="'.$rowId.'" data-product-id="'.$product->id.'"'.$addonAttr.'>-</button>
|
||||
</span>
|
||||
<input type="text" class="form-control text-center input-extra table-input-event-onchange" name="product_qty_'.$product->id.'" data-row-id="'.$rowId.'" data-product-id="'.$product->id.'" value="'.$qty.'">
|
||||
<input type="text" class="form-control text-center input-extra table-input-event-onchange" name="'.$inputName.'" data-row-id="'.$rowId.'" data-product-id="'.$product->id.'"'.$addonAttr.' value="'.$qty.'">
|
||||
<span class="input-group-append">
|
||||
<button type="button" class="btn btn-secondary icon-btn md-btn-extra add-product-basket" data-row-id="'.$rowId.'" data-product-id="'.$product->id.'">+</button>
|
||||
<button type="button" class="btn btn-secondary icon-btn md-btn-extra add-product-basket" data-row-id="'.$rowId.'" data-product-id="'.$product->id.'"'.$addonAttr.'>+</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>';
|
||||
|
|
@ -823,8 +835,12 @@ class OrderController extends Controller
|
|||
}
|
||||
|
||||
$isAbo = str_contains($is_for, 'abo');
|
||||
$isAddon = $isAbo && filter_var($data['abo_addon'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
$qty = isset($data['qty']) ? (int) $data['qty'] : 0;
|
||||
if ($qty > 0 && ! ProductOrderContext::isProductAllowedInContext($product, $isAbo, $is_for)) {
|
||||
if ($qty > 0 && $isAddon && ! ProductOrderContext::productMatchesShowOn($product, ProductOrderContext::oneTimeShowOnIds())) {
|
||||
return $this->cartUpdateRejected($data, __('msg.cart_product_not_allowed_for_order_type'));
|
||||
}
|
||||
if ($qty > 0 && ! $isAddon && ! ProductOrderContext::isProductAllowedInContext($product, $isAbo, $is_for)) {
|
||||
return $this->cartUpdateRejected($data, __('msg.cart_product_not_allowed_for_order_type'));
|
||||
}
|
||||
|
||||
|
|
@ -837,6 +853,13 @@ class OrderController extends Controller
|
|||
$image = $product->images->first()->slug;
|
||||
}
|
||||
|
||||
$options = ['image' => $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];
|
||||
if ($isAddon) {
|
||||
// Eigene Cart-Zeile (rowId hängt an den Optionen): wird beim Checkout als
|
||||
// is_abo_addon persistiert und nicht in die dauerhaften Abo-Positionen übernommen.
|
||||
$options['abo_addon'] = true;
|
||||
}
|
||||
|
||||
// Get the cart item
|
||||
if ($is_for === 'ot-customer' || $is_for === 'abo-ot-customer') {
|
||||
$cartItem = Yard::instance('shopping')
|
||||
|
|
@ -847,7 +870,7 @@ class OrderController extends Controller
|
|||
round($product->getPriceWith(Yard::instance('shopping')->getUserTaxFree(), false, Yard::instance('shopping')->getUserCountry()), 1),
|
||||
false,
|
||||
false,
|
||||
['image' => $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]
|
||||
$options
|
||||
);
|
||||
} else {
|
||||
$cartItem = Yard::instance('shopping')
|
||||
|
|
@ -858,7 +881,7 @@ class OrderController extends Controller
|
|||
$product->getPriceWith(Yard::instance('shopping')->getUserTaxFree(), true, Yard::instance('shopping')->getUserCountry()),
|
||||
false,
|
||||
false,
|
||||
['image' => $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]
|
||||
$options
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -891,7 +914,7 @@ class OrderController extends Controller
|
|||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function cartUpdateRejected(array $data, string $message): \Illuminate\Http\JsonResponse
|
||||
private function cartUpdateRejected(array $data, string $message): JsonResponse
|
||||
{
|
||||
$data['error_message'] = $message;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue