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

@ -0,0 +1,73 @@
{{-- Einmalprodukte bei der Abo-Anlage: eigene Produktliste (Normalsortiment), Zeilen landen
mit Cart-Option abo_addon=true im selben Warenkorb (nur erste Lieferung, kein Abo-Bestandteil). --}}
<div class="card mt-4">
<div class="card-body pb-2">
<h4 class="mb-1"><i class="fa fa-bolt text-warning"></i> {{ __('abo.onetime_create_hl') }}</h4>
<p class="small text-muted mb-0">{{ __('abo.onetime_create_info') }}</p>
</div>
<div class="card-datatable table-responsive">
<table class="table table-striped table-bordered" id="datatables-onetime-list" data-url="{{ route('user_order_my_perform_request') }}">
<thead>
<tr>
<th>{{ __('tables.image') }}</th>
<th>{{ __('tables.product') }}</th>
@if ($template === 'me')
<th>{{ __('tables.my_price_net') }}</th>
<th>{{ __('tables.my_price_gross') }}</th>
@else
<th>{{ __('tables.vk_price') }} {{ __('tables.net') }}</th>
<th>{{ __('tables.vk_price') }} {{ __('tables.gross') }}</th>
@endif
<th>{{ __('tables.points') }}</th>
<th><span class="no-line-break">{{ __('tables.contents') }} (ml)</span></th>
<th>{{ __('tables.article_no') }}</th>
<th>#</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
<script>
$(document).ready(function () {
$('#datatables-onetime-list').DataTable({
"processing": true,
"serverSide": true,
ajax: {
url: '{!! route('user_order_my_datatable') !!}',
data: function (d) {
d.shipping_is_for = $('input[name=shipping_is_for]').val();
d.is_abo = $('input[name=is_abo]').val();
d.abo_addon = 1;
}
},
"order": [[6, "asc"]],
"columns": [
{ data: 'picture', name: 'picture', searchable: false, width: 35 },
{ data: 'product', name: 'product' },
@if ($template === 'me')
{ data: 'price_net', name: 'price_net', searchable: false, orderable: false },
{ data: 'price_gross', name: 'price_gross', searchable: false, orderable: false },
@else
{ data: 'customer_price_net', name: 'customer_price_net', searchable: false, orderable: false },
{ data: 'customer_price_gross', name: 'customer_price_gross', searchable: false, orderable: false },
@endif
{ data: 'points', name: 'points', searchable: false },
{ data: 'contents_total', name: 'contents_total', searchable: false },
{ data: 'number', name: 'number' },
{ data: 'action', name: 'action', searchable: false, orderable: false },
],
"bLengthChange": false,
"iDisplayLength": 1000,
"paging": false,
"language": {
"url": "/js/datatables-{{ \App::getLocale() }}.json"
},
drawCallback: function (settings) {
IqShoppingCart.reInit();
}
});
});
</script>

View file

@ -23,6 +23,10 @@
</div>
</div>
@if($is_abo)
@include('user.order._onetime_list')
@endif
{!! Form::open(['action' => route('user_order_my_payment', [$for, $delivery_id]), 'class' => 'form-horizontal']) !!}
<input type="hidden" name="shipping_is_for" value="{{$for}}">
<input type="hidden" name="is_abo" value="{{$is_abo}}">

View file

@ -23,6 +23,10 @@
</div>
</div>
@if($is_abo)
@include('user.order._onetime_list')
@endif
{!! Form::open(['action' => route('user_order_my_payment', [$for, $delivery_id]), 'class' => 'form-horizontal']) !!}
<input type="hidden" name="shipping_is_for" value="{{$for}}">
<input type="hidden" name="is_abo" value="{{$is_abo}}">

View file

@ -23,6 +23,10 @@
</div>
</div>
@if($is_abo)
@include('user.order._onetime_list')
@endif
{!! Form::open(['action' => route('user_order_my_payment', [$for, $delivery_id]), 'class' => 'form-horizontal']) !!}
<input type="hidden" name="shipping_is_for" value="{{$for}}">
<input type="hidden" name="is_abo" value="{{$is_abo}}">

View file

@ -165,7 +165,9 @@
<div class="col-12 col-sm-6 col-md-7 description">
<div class="media-body">
<div class="d-block text-body" style="font-size: 15px; font-weight: 500;">
{{ $row->name }} @if (isset($is_abo) && $is_abo)
{{ $row->name }} @if ($row->options->abo_addon)
<span class="badge badge-warning">{{ __('abo.onetime_badge_first_delivery') }}</span>
@elseif (isset($is_abo) && $is_abo)
{!! get_abo_type_badge_by_product($product) !!}
@endif
</div>
@ -184,7 +186,8 @@
@if (!$row->options->comp)
<a href="#"
class="auto-delete-product remove_item_form_cart product-tooltip"
data-row-id="{{ $row->rowId }}" data-product-id="{{ $product->id }}"><i
data-row-id="{{ $row->rowId }}" data-product-id="{{ $product->id }}"
data-abo-addon="{{ $row->options->abo_addon ? 1 : 0 }}"><i
class="fa fa-times"></i> {{ __('order.article_remove') }}</a>
@else
@if (Yard::instance('shopping')->getNumComp() > 1)
@ -218,18 +221,21 @@
<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>
data-product-id="{{ $product->id }}"
data-abo-addon="{{ $row->options->abo_addon ? 1 : 0 }}">-</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 }}"
data-abo-addon="{{ $row->options->abo_addon ? 1 : 0 }}"
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>
data-product-id="{{ $product->id }}"
data-abo-addon="{{ $row->options->abo_addon ? 1 : 0 }}">+</button>
</span>
</div>
</div>