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