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:
Kevin 2026-07-21 14:36:40 +00:00
parent 1e7ae92d0c
commit cdb121fde0
32 changed files with 1479 additions and 52 deletions

View file

@ -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

View file

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