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

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