Abo Einmalprodukte und Bestätigung abschließen

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Kevin 2026-06-05 15:28:08 +00:00
parent 2bdc9ada3c
commit 2269ce031f
57 changed files with 3647 additions and 371 deletions

View file

@ -0,0 +1,387 @@
# Entwicklungsplan: Einmalige Produkte zum Abo-Versand hinzufügen
> **Status:** In Planung · **Erstellt:** 2026-06-05 · **Briefing:** `docs/salescenter/ABO - Bestellungen.md`
## Dokumentations-Hinweis (verbindlich)
Dieser Plan wird **Schritt für Schritt** abgearbeitet. Für **jede Phase** gilt:
- Der **Umsetzungsstatus** in diesem Dokument wird nach Abschluss eines Schritts auf `ERLEDIGT` gesetzt (mit Datum, betroffenen Dateien, Zeilenangaben und kurzer Begründung analog zu `dev/2026-02-19/plan-wizard-starterpaket.md`).
- **Alle Phasen, Hinweise und Optimierungen** (Abschnitt „Zu beachtende Punkte") werden sauber dokumentiert auch Abweichungen vom Plan, getroffene Detail-Entscheidungen und Verworfenes (mit Begründung).
- Jede Änderung wird **programmatisch getestet** (Pest), bevor die Phase als erledigt gilt.
- Nach Code-Änderungen läuft `./vendor/bin/pint`.
---
## 1. Ziel & Briefing-Zusammenfassung
Berater und Endkunden sollen **kurz vor der nächsten Abo-Ausführung** (Zeitfenster X Tage, konfigurierbar) **einmalig** Produkte aus dem **normalen Bestellsortiment** (nicht dem Abo-Sortiment) zu ihrer anstehenden Abo-Lieferung hinzufügen können.
**Nutzen:** Versandkosten und einen zweiten Versandweg sparen (Nachhaltigkeit).
Kernpunkte:
- **Einmalig**, nicht dauerhaft nach erfolgreicher Abo-Ausführung werden die Einmal-Artikel wieder entfernt; nur die reinen Abo-Produkte bleiben.
- **Getrennte Sortimente:** Abo (`show_on` 12/13) vs. Bestellsortiment (`show_on` 2 = Berater, `show_on` 3 = Endkunde).
- **Zwei Anlaufstellen:** Sales Center `my.mivita.care` (Berater) und Portal `in.mivita.care` (Endkunde).
- **E-Mail-Vorlauf** mit zielgruppenspezifischem Link + Hinweis auf Deckung der Zahlungsweise und Nachhaltigkeit.
- **UI:** Card über den Abo-Produkten → Modal mit Bestellsortiment (inkl. Steuer + Endpreis), `+`-Buttons, zweiter „Warenkorb" mit getrennten Zwischensummen (oben Einmal-Artikel, unten Abo), separate Gesamtsummen-Card (kombinierter Versand nach Gesamtgewicht), sticky Warenkorb-Seitenleiste.
- **Rechnung & Lieferschein:** Eine Gesamtrechnung, Einmal-Artikel klar gekennzeichnet und unterhalb der Abo-Produkte abgegrenzt.
---
## 2. Geklärte Rahmenbedingungen (Stand 2026-06-05)
| # | Entscheidung |
| -- | ------------ |
| 1 | Zeitfenster-Setting Default = **4 Tage**; Fenster gilt von `next_date X` bis Ausführung. |
| 2 | **Bei Zahlungsfehler bleiben** Einmal-Artikel erhalten. User **und** Admin können Retry selbst anstoßen. User kann Artikel **jederzeit im Fenster** hinzufügen/entfernen. |
| 3 | Einmal-Artikel **bleiben bestehen** bis zur nächsten **erfolgreichen** Ausführung oder bis sie der User manuell entfernt auch bei Pause/Verschiebung. |
| 4 | **Preis-Snapshot** beim Hinzufügen (Transparenz: hinzugefügter Preis gilt, nicht Tagespreis). |
| 5 | Deckungsprüfung = **nur Text-Hinweis** (keine API-Abfrage möglich; Fehler kommt erst bei Abbuchung). |
| 6 | Sortiment: **`show_on=2`** (Berater) / **`show_on=3`** (Endkunde), **keine** Min-/Höchstmenge (wie normale Bestellung). |
| 7 | Komplette Versandlogik gilt: Gewicht ↑ → Versand ↑, ggf. **neues Kompensationsprodukt** nötig; im End-Warenkorb berücksichtigt und bei nächster Ausführung **zurückgesetzt**. |
| 8 | Endkunden-Route passt; jeder Kunde hat **nur ein Abo** (kleine IDs). |
---
## 3. Ist-Zustand (Analyse-Ergebnis)
| Bereich | Status heute | Schlüsseldateien |
| ------- | ------------ | ---------------- |
| Abo-Datenmodell | `UserAbo` + `UserAboItem` (dauerhafte Positionen, `show_on` 12/13) + `UserAboOrder` (Ausführungs-Historie) | `app/Models/UserAbo.php`, `app/Models/UserAboItem.php`, `app/Models/UserAboOrder.php` |
| Ausführung | Cron `user:make_abo_order` (täglich 04:00) → `UserMakeOrder` baut `ShoppingOrder` (`is_abo=1`) aus `user_abo_items`, zahlt via Payone | `app/Console/Commands/UserMakeAboOrder.php`, `app/Cron/UserMakeOrder.php` |
| Cart | Kein eigenes Cart-System `AboOrderCart` befüllt `Yard::instance('shopping')` aus Items, berechnet Versand nach Gewicht, schreibt `user_abos.amount` | `app/Services/AboOrderCart.php`, `app/Services/Yard.php` |
| Sortiment-Trennung | Über `ProductOrderContext::allowedShowOnIds()` (Berater `2`/Abo `12,13`; Kunde `3`/Abo `12,13`) | `app/Services/ProductOrderContext.php`, `app/Http/Controllers/User/OrderController.php:598-639` |
| „Produkt hinzufügen" (bestehend) | Erhöht **dauerhaft** das Abo-Sortiment (Action `addProduct`) **nicht** das hier benötigte Verhalten | `app/Http/Controllers/User/AboController.php`, `Portal/AboController.php` |
| Retry | Vorhanden im Admin per Knopfdruck (`AboRetryPaymentService::retry()`), nutzt `UserMakeOrder::makeShoppingOrder()` | `app/Services/AboRetryPaymentService.php`, `app/Http/Controllers/Admin/AboController.php:106` |
| Rechnung/Lieferschein | Flache Iteration über `shopping_order_items` **keine** Abo-/Einmal-Trennung | `resources/views/pdf/invoice-detail.blade.php`, `resources/views/pdf/delivery-detail.blade.php` |
| Settings | Slug-basiertes `Setting`-Model; „Abo Einstellungen"-Card existiert (`abo-min-duration`) | `app/Models/Setting.php`, `resources/views/admin/settings/index.blade.php:63-75` |
| E-Mail-Vorlauf | **Existiert nicht** | |
| Domains | `Util::getMyMivitaUrl()` (Berater) / `Util::getMyMivitaPortalUrl()` (Endkunde) | `app/Services/Util.php` |
---
## 4. Architektur-Entscheidungen
### 4.1 Eigene Tabelle `user_abo_one_time_items` (mit Preis-Snapshot)
Bewusst **getrennt** von `user_abo_items`, damit Einmal-Artikel nie in die dauerhafte Abo-Logik (Provision, Mindestlaufzeit, History, num_comp der Abo-Items) geraten und sauber gelöscht werden können.
**Spalten:** `id`, `user_abo_id` (FK, cascade), `product_id` (FK), `comp` (für ggf. auto. Kompensationsprodukte), `qty`, `price`, `price_net`, `tax_rate`, `tax`, `price_vk_net`, `discount`, `points`, `status`, `created_at`, `updated_at`, `deleted_at` (SoftDeletes).
Preis-Felder werden **beim Hinzufügen** als Snapshot befüllt (Entscheidung #4).
### 4.2 Kennzeichnung in `shopping_order_items`
Neue Spalte **`is_abo_addon`** (boolean, default `false`). Es bleibt **eine** Bestellung/Rechnung; die Trennung in Rechnung/Lieferschein erfolgt über dieses Flag (Abo-Produkte oben, Einmal-Artikel abgegrenzt darunter).
### 4.3 Kombinierter Versand, getrennte Zwischensummen
Einmal-Artikel + Abo-Items landen im **selben `Yard`-Cart** → Versand wird automatisch über das **Gesamtgewicht** berechnet (das ist der Nutzen des Features). Einmal-Artikel tragen im Cart die Option **`abo_addon => true`** → ermöglicht getrennte Zwischensummen-Berechnung in der Live-Ansicht ohne DB-Roundtrip.
### 4.4 `user_abos.amount` entkoppeln
`AboOrderCart::makeOrderYard()` überschreibt aktuell `user_abos.amount` bei **jedem** Aufruf (auch beim bloßen Ansehen). `amount` muss weiterhin nur den **reinen Abo-Betrag** enthalten. Der **kombinierte** Betrag (inkl. Einmal-Artikel) wird nur für die Abbuchung im Cron/Retry berechnet, nicht persistiert.
### 4.5 Zentrale Purge-Logik
Eine Methode „Einmal-Artikel des Abos löschen", aufgerufen **nur bei erfolgreicher** Ausführung sowohl im Cron-Erfolgszweig (`UserMakeOrder`/`UserMakeAboOrder``updateAbo`) als auch in `AboRetryPaymentService::markAboSuccess()`. Bei Fehler: **behalten**.
### 4.6 Zeitfenster-Setting
Neuer Slug **`abo-onetime-window-days`** (Typ `int`, Default `4`) in der „Abo Einstellungen"-Card. Steuert Sichtbarkeit der Card/des Modals **und** den Versandzeitpunkt der Reminder-Mail. **Unabhängig** von den hartkodierten Lock-Fristen in `AboRepository` (siehe Punkt 9.2).
---
## 5. Betroffene Dateien (Übersicht)
| Datei | Änderung |
| ----- | -------- |
| `database/migrations/...create_user_abo_one_time_items_table.php` | **NEU** Tabelle mit Snapshot-Feldern |
| `database/migrations/...add_is_abo_addon_to_shopping_order_items_table.php` | **NEU** Flag-Spalte |
| `app/Models/UserAboOneTimeItem.php` | **NEU** Model + Relationen |
| `database/factories/UserAboOneTimeItemFactory.php` | **NEU** Factory |
| `app/Models/UserAbo.php` | Relation `one_time_items()` |
| `app/Models/ShoppingOrderItem.php` | `is_abo_addon` fillable/cast, Scopes |
| `app/Services/AboHelper.php` | `isOneTimeWindowOpen()`, Purge-Helper |
| `app/Services/AboOrderCart.php` (oder neuer `AboOneTimeCart`) | Einmal-Artikel in Yard laden, getrennte Zwischensummen, num_comp inkl. Gesamtgewicht |
| `app/Http/Controllers/User/AboController.php` | Actions `addOneTime` / `updateOneTime` / `removeOneTime` / User-Retry |
| `app/Http/Controllers/Portal/AboController.php` | dieselben Actions für Endkunde |
| `app/Http/Controllers/ModalController.php` | Modal-Branch für Einmal-Produkt-Auswahl |
| `app/Http/Requests/...` | **NEU** Form Requests für die Actions |
| `app/Cron/UserMakeOrder.php` | Einmal-Artikel mit in Bestellung, `is_abo_addon=true`, kombinierter Betrag |
| `app/Console/Commands/UserMakeAboOrder.php` | Purge nach Erfolg (zentral) |
| `app/Services/AboRetryPaymentService.php` | Purge im `markAboSuccess()` |
| `app/Console/Commands/AboSendExecutionReminder.php` | **NEU** Reminder-Command |
| `app/Console/Kernel.php` | Schedule-Eintrag Reminder |
| `app/Mail/MailAboExecutionReminder.php` | **NEU** Mailable |
| `resources/views/emails/abo_execution_reminder.blade.php` | **NEU** Mail-Template |
| `resources/views/user/abo/detail.blade.php` | Card + zweite Liste + Summen + sticky Sidebar |
| `resources/views/portal/abo/my_abo.blade.php` | dito für Endkunde |
| `resources/views/admin/abo/_order_onetime*.blade.php` | **NEU** Einmal-Artikel-Liste/Summen |
| `resources/views/user/abo/modal_abo_onetime_products.blade.php` | **NEU** Modal Bestellsortiment |
| `public/js/iq-modal-cart.js` | JS für neue Actions |
| `resources/views/pdf/invoice-detail.blade.php` | Gruppierung Abo / Einmal |
| `resources/views/pdf/delivery-detail.blade.php` | Gruppierung Abo / Einmal |
| `resources/views/admin/settings/index.blade.php` | Setting `abo-onetime-window-days` |
| `resources/lang/{de,en,es,fr}/abo.php`, `email.php` | Übersetzungen |
| `tests/Feature/...`, `tests/Unit/...` | **NEU** Tests |
---
## 6. Phasenplan & Umsetzungsstatus
### Phase 1 Datenmodell & Settings · `ERLEDIGT (2026-06-05)`
- [x] 1.1 Migration `user_abo_one_time_items` (inkl. Snapshot-Felder, FKs, SoftDeletes).
- [x] 1.2 Model `UserAboOneTimeItem` (fillable, casts, Relationen `product()`, `user_abo()`) + Factory.
- [x] 1.3 Migration `is_abo_addon` (boolean, default false) auf `shopping_order_items`.
- [x] 1.4 `ShoppingOrderItem`: `is_abo_addon` in fillable/casts, Scopes `aboItems()` / `addonItems()`.
- [x] 1.5 Relation `one_time_items()` (hasMany) auf `UserAbo`.
- [x] 1.6 Setting `abo-onetime-window-days` (int, Default 4) in `admin/settings/index.blade.php` (Abo-Card).
- [x] 1.7 `AboHelper::isOneTimeWindowOpen(UserAbo $abo): bool` (vergleicht `next_date` mit `now()` + Setting).
- [x] 1.8 Tests: Window-Logik, Setting-Default/Custom, Model-Cast/Relation, Scopes.
**Doku (Stand 2026-06-05):**
- **1.1** `database/migrations/2026_06_05_120000_create_user_abo_one_time_items_table.php` Tabelle mit Snapshot-Spalten (`price`, `price_net`, `tax_rate`, `tax`, `price_vk_net`, `discount`, `points`), `comp`, `qty`, `status`, `timestamps`, `softDeletes`. FK `user_abo_id``user_abos` (cascade), `product_id``products`. Spalten-Typen analog `shopping_order_items`.
- **1.2** `app/Models/UserAboOneTimeItem.php` (HasFactory + SoftDeletes, Casts/Fillable, Relationen `product()`/`user_abo()`, `getFormattedPrice()`/`getFormattedTotalPrice()`). Factory `database/factories/UserAboOneTimeItemFactory.php`.
- **1.3** `database/migrations/2026_06_05_120100_add_is_abo_addon_to_shopping_order_items_table.php` `boolean is_abo_addon default(false) after('comp')`.
- **1.4** `app/Models/ShoppingOrderItem.php` `is_abo_addon` in fillable + Cast `bool`; Scopes `scopeAboItems()` (`is_abo_addon = false`) und `scopeAddonItems()` (`is_abo_addon = true`).
- **1.5** `app/Models/UserAbo.php` `one_time_items()` (hasMany) + Docblock `@property-read`.
- **1.6** `resources/views/admin/settings/index.blade.php` Feld „Zeitfenster für einmalige Produkte vor Ausführung (Tage)" (Slug `abo-onetime-window-days`, Typ `int`) in der Abo-Card, inkl. Hilfetext (Standard 4). Speicherung über bestehenden `SettingController@store`-Mechanismus.
- **1.7** `app/Services/AboHelper.php` Konstante `DEFAULT_ONETIME_WINDOW_DAYS = 4`, `getOneTimeWindowDays()` (liest Setting, Fallback Default bei 0/leer), `isOneTimeWindowOpen(UserAbo)` (rein zeitliche Prüfung: `next_date` vorhanden, nicht in der Vergangenheit, `diffInDays <= Fenster`). Abo-Zustand (active/status) prüfen die Aufrufer.
- **1.8** `tests/Feature/AboOneTimeWindowTest.php` 13 Tests, alle grün (Window-Grenzfälle Tag X / X+1 / Vergangenheit / kein `next_date`, Setting-Default/Custom, Model-Cast + Relation-Typ, Scope-SQL + bool-Cast).
- **Pint:** ausgeführt (`--dirty`), `ShoppingOrderItem.php` formatiert.
**WICHTIG Nebenbefund / Fix (siehe auch Abschnitt 9.7):** Die RefreshDatabase-Testsuite war auf SQLite **generell** kaputt: Migration `2026_05_12_113939_add_french_locale_to_trans_languages.php` setzt die Tabelle `trans_languages` voraus, die per Migration nie erzeugt wird (existiert nur in der echten MySQL-DB). Auch bestehende Tests (`AboRepositoryDateChangeTest`) schlugen dadurch fehl. Fix: `up()`/`down()` mit `Schema::hasTable('trans_languages')`-Guard versehen (produktionssicher, da Tabelle dort existiert; in Tests wird übersprungen). Dadurch läuft die gesamte RefreshDatabase-Suite wieder.
---
### Phase 2 Backend-Logik (Berater + Endkunde) · `ERLEDIGT (2026-06-05)`
- [x] 2.1 Cart-Befüllung erweitern: Einmal-Artikel mit **Snapshot-Preis** + Option `abo_addon=true` zusätzlich in den Yard.
- [x] 2.2 Getrennte Zwischensummen-Methode (Einmal vs. Abo).
- [x] 2.3 Kompensationsprodukt-Logik auf **Gesamtgewicht inkl. Einmal-Artikel** (über Yard-Aufrufreihenfolge).
- [x] 2.4 AJAX-Action `oneTime` (add/update/remove) in `User\AboController` **und** `Portal\AboController` Zeitfenster-Guard, Sortiment-Filter, Snapshot, Berechtigungs-Check.
- [x] 2.5 Form Request `AboOneTimeItemRequest` + Übersetzungen (DE/EN/ES/FR).
- [x] 2.6 Tests (Service add/update/remove, Sortiment-Guard, Snapshot, getrennte Summen, FormRequest-Regeln).
**Doku (Stand 2026-06-05):**
- **2.1** `app/Services/AboOrderCart.php``addOneTimeItemsToYard(UserAbo)`: lädt `one_time_items` mit eingefrorenem Snapshot-Preis und Cart-Option `abo_addon=true` (+ `one_time_item_id`, `weight` live, `points`/`tax_rate` aus Snapshot) in `Yard::instance('shopping')`, danach `reCalculateShippingPrice()`. **Verändert `amount` nicht** (wird nach `makeOrderYard()` aufgerufen → 9.1 erfüllt).
- **2.1/Snapshot** `AboOrderCart::buildOneTimeSnapshot(Product, UserAbo)`: berechnet `price` (Berater: `getPriceWith(tax_free,true,…)`, Kunde: `round(getPriceWith(tax_free,false,…),1)`), `price_net`, `tax_rate`, `tax`, `price_vk_net`, `discount` (Berater-Marge), `points` konsistent zu `addProductToCart()`.
- **2.2** `AboOrderCart::getSplitSummary()`: iteriert Yard-Content, trennt per `options->abo_addon` in `abo`/`one_time` (net/gross/tax) und liefert kombinierten Versand + `total_with_shipping` + `has_one_time`.
- **2.3** Reihenfolge in der Action: `initYard → makeOrderYard → addOneTimeItemsToYard → checkNumOfCompProducts`. Da die Einmal-Artikel **vor** `checkNumOfCompProducts` im Yard liegen, berücksichtigt `getNumComp()` automatisch das Gesamtgewicht. Beim Ausführen (Phase 4) wird ohne Einmal-Artikel neu gerechnet → Reset.
- **2.4** `User\AboController@oneTime()` + `Portal\AboController@oneTime()`: prüfen `isOneTimeWindowOpen()` (403 wenn zu), delegieren an gemeinsamen Service, bauen Yard neu auf und liefern JSON `{response, message, summary, one_time_items}`. Berechtigung via `checkPermissions()`/`checkPortalPermission()`.
- **Service** `app/Services/AboOneTimeService.php`: gemeinsame `handleAction()` (add/update/remove) für beide Controller (DRY). Sortiment-Guard via `ProductOrderContext::allowedShowOnIds(false, me|ot-customer)``['2']`/`['3']`. Mengen-Clamp 1100. Fremd-Item-Schutz über `user_abo_id`-Filter.
- **Routen:** `user_abos_onetime` in `routes/domains/crm.php` (`POST /user/abos/onetime/{view}/{id}`) und `routes/domains/portal.php` (`POST portal/my-subscriptions/onetime/{view}/{id}`).
- **2.5** `app/Http/Requests/Abo/AboOneTimeItemRequest.php` (array-Regeln, `required_if`-Logik, lokalisierte Messages). Neue Keys in `resources/lang/{de,en,es,fr}/abo.php`: `onetime_window_closed`, `onetime_product_not_allowed`, `onetime_action_required`, `onetime_action_invalid`, `onetime_qty_required`.
- **2.6** `tests/Feature/AboOneTimeServiceTest.php` 10 Tests grün (gesamt mit Phase 1: **23 grün**).
- **Pint:** ausgeführt; ungenutzte Imports entfernt.
**Abweichungen / Entscheidungen (dokumentiert):**
1. **Gemeinsamer Service statt inline-Duplikat:** Add/Update/Remove liegen in `AboOneTimeService`, nicht doppelt in beiden Controllern (die bestehende `update()`-Multiplex-Logik wurde bewusst nicht angefasst).
2. **Dedizierter Endpoint statt Multiplex-`update()`:** Damit der `FormRequest` sauber greift (Auto-Validation), bekommen die Einmal-Aktionen eine eigene Route/Methode `oneTime`, statt sie in die action-gemultiplexte `update()` zu zwängen (dort sind keine FormRequests im Einsatz).
3. **Datatable + Modal für die Produktauswahl** (normales Sortiment `show_on` 2/3) sowie die JS-Anbindung sind **UI** und gehören zu **Phase 3** hier bewusst noch nicht umgesetzt.
4. **Teilabdeckung Tests:** Die voll integrierte kombinierte Versand-/`num_comp`-Berechnung (mit echtem Yard-Init für `me`/`ot`) wird in **Phase 4** (Ausführung) getestet; Phase 2 deckt Split-Mathematik (`getSplitSummary`) und Snapshot isoliert ab.
---
### Phase 3 Frontend / Views · `ERLEDIGT (2026-06-05)`
- [x] 3.1 Neue Card oberhalb der Abo-Produkte in `user/abo/detail.blade.php` + `portal/abo/my_abo.blade.php` (nur wenn Fenster offen) inkl. Nachhaltigkeits-/Versand-Spar- und Deckungs-Hinweis (Text).
- [x] 3.2 Modal `modal_abo_onetime_products.blade.php` (Yajra-Datatable Sortiment 2/3, Spalten Steuer + berechneter Endpreis, `+`-Button pro Zeile).
- [x] 3.3 Übersicht: zweite Produktliste (Einmal) mit eigener Zwischensumme **oben**, Abo-Liste mit Zwischensumme **unten** (`split_mode`).
- [x] 3.4 Separate Gesamtsummen-Card unter beiden Listen (Zwischensumme Einmal + Zwischensumme Abo + Versand nach Gesamtgewicht + Steuer + Gesamtsumme).
- [x] 3.5 Sticky Warenkorb-Seitenleiste (Bootstrap 4 `sticky-top`) mit Detail-Zusammenfassung.
- [x] 3.6 Eigenes JS-Modul `iq-abo-onetime.js` für die neuen Actions (Mengen +/-, Entfernen, Modal laden) getrennt von `iq-modal-cart.js` (eigener Endpoint, eigene Holder).
- [ ] 3.7 Visuelle Prüfung (Berater + Endkunde) durch den User vorzunehmen; **kein** Asset-Build nötig (JS liegt in `public/js`, via `asset()` eingebunden).
**Doku (2026-06-05):**
- **Neue View-Partials** (`resources/views/admin/abo/`):
- `_order_onetime.blade.php` Card mit Überschrift, Info-/Deckungs-/Nachhaltigkeits-Hinweis, „Einmaliges Produkt hinzufügen"-Button (Modal-Trigger `data-action="abo-add-onetime"`), Holder `#insert_show_onetime_order`.
- `_order_onetime_show.blade.php` Einmal-Artikel-Liste (Bild, Name + „Einmalig"-Badge, Mengen-Stepper, Entfernen, Position­spreis aus Snapshot) + Zwischensumme Einmal aus `$summary`.
- `_order_combined_summary.blade.php` Gesamtsummen-Card: Zwischensumme Einmal + Zwischensumme Abo + Lieferland + Versand + (Netto/Steuer) + Gesamtsumme (alle aus `Yard::instance('shopping')`-Kombiwerten + `$summary`).
- **Modal:** `resources/views/user/abo/modal_abo_onetime_products.blade.php` DataTable über `user_abo_onetime_datatable`, `+`-Button-Spalte (`.add-onetime-product-basket`).
- **`_order_abo_show.blade.php`:** Neuer optionaler Schalter `$split_mode` → zeigt im Split-Modus **nur** die Abo-Zwischensumme (aus `$summary['abo']['gross']`), Versand/Steuer/Gesamt entfallen (wandern in die Gesamtsummen-Card). Default `false` ⇒ bestehende Aufrufer unverändert. `_order_abo.blade.php` reicht `$split_mode`/`$summary` durch (mit Defaults).
- **Nachtrag 2026-06-05:** Im Split-Modus zeigt `_order_abo_show.blade.php` zusätzlich zur Abo-Zwischensumme jetzt auch den **Gesamtbetrag der nächsten Lieferung** (`$summary['total_with_shipping']`) analog zur Einmalprodukt-Liste. Versand-/Steuerdetailzeilen bleiben weiterhin in der seitlichen Gesamtsummen-Card, damit die Abo-Liste nicht wieder den vollständigen Warenkorb dupliziert.
- **Layout** `user/abo/detail.blade.php` + `portal/abo/my_abo.blade.php`: Bei offenem Fenster zweispaltiges `row` → links (`col-lg-8`) Einmal-Card + Abo-Card (split) + Comp-Produkte, rechts (`col-lg-4`) sticky Gesamtsummen-Card (`sticky-top`, `top:1rem`). Bei geschlossenem Fenster unveränderter Single-Column-Aufbau.
- **JS** `public/js/iq-abo-onetime.js` (neu, eingebunden in beide Detail-Views, init `IqAboOneTime.init()`): postet an `data-onetime-route` (`user_abos_onetime`) mit `action add|update|remove`; rendert `#insert_show_onetime_order`, `#insert_show_products_order` (Abo-Split) und `#combined_summary_card` aus den HTML-Feldern der JSON-Antwort neu; Fehler-Handling über `#insert_onetime_error_message`.
- **Controller (User + Portal):**
- `oneTimeDatatable($id)` neu Sortiment `show_on=2` (Berater/`me`) bzw. `show_on=3` (Endkunde/`ot`/Portal), Spalten inkl. Netto-/Brutto-Endpreis.
- `oneTime()` liefert zusätzlich `html_onetime`, `html_abo` (split), `html_summary` für das Live-Re-Rendering.
- `detail()`/`myAbo()` legen bei offenem Fenster die Einmal-Artikel mit in den Yard (`addOneTimeItemsToYard`) und übergeben `one_time_window_open` + `summary`.
- `ModalController@load` (CRM) und `Portal\AboController@modalLoad` (Portal): Branch `abo-add-onetime` → Onetime-Modal.
- **Routen:** `user_abo_onetime_datatable` (CRM `routes/domains/crm.php`, Portal `routes/domains/portal.php`).
- **Übersetzungen** (`de/en/es/fr/abo.php`): `onetime_card_hl`, `onetime_card_info`, `onetime_important_hl`, `onetime_coverage_hint`, `onetime_reset_hint`, `add_onetime_product`, `onetime_subtotal`, `onetime_next_delivery_total`, `abo_subtotal`, `abo_next_delivery_total`, `onetime_empty`, `onetime_badge`, `combined_summary_hl`.
- **Tests:** `tests/Feature/AboOneTimeViewTest.php` (4 Render-Tests: Einmal-Liste mit Position/Zwischensumme, Leer-Hinweis, kombinierte Gesamtsumme, Abo-Liste im Split-Modus). Gemeinsame Test-Helper (`makeShopEnv/makeMeAbo/makeProduct`) nach `tests/Pest.php` verschoben (guarded), Duplikate aus `AboOneTimeServiceTest.php` entfernt. **27 Tests grün** (Phase 13). Pint: pass.
- **Sticky-Sidebar (Nachtrag 2026-06-05):** Bootstrap-`sticky-top` wurde durch eine **JS-gesteuerte Lösung** (`IqAboOneTime.initSticky/updateSticky` in `iq-abo-onetime.js`) ersetzt, da `position: sticky` in diesem Appwork-Layout durch Vorfahren-`overflow` unzuverlässig ist. Die Summen-Card (`#combined_summary_card` in `#combined_summary_col`) wird per `position: fixed` am oberen Rand angeheftet (Abstand 16 px) und **maximal bis zum Ende der Spalte** mitgeführt (Clamp `colRect.bottom cardHeight`). Scroll-Listener im Capture-Modus (erfasst beliebige Scroll-Container), `requestAnimationFrame`-Throttle, Reset unter `lg` (einspaltig) und Re-Berechnung nach jedem Re-Render. Views nutzen statt `sticky-top` die Klasse `js-abo-sticky`.
- **Bestätigung Einmalprodukte (Nachtrag 2026-06-05):** `UserAboOneTimeItem` wurde um `confirmed_qty`/`confirmed_at` erweitert. `AboOneTimeService` unterstützt `confirm`/`discard`: Bestätigen friert den aktuellen Stand kostenpflichtig ein (`status=1`), Verwerfen stellt den zuletzt bestätigten Stand wieder her oder entfernt unbestätigte neue Artikel endgültig. Die Einmalprodukt-Liste zeigt unten eine rechtliche Bestätigungsfläche mit Zwischensumme, Gesamtbetrag der nächsten Lieferung, AGB-/Widerrufs-Links und den Buttons „Änderungen verwerfen" / „Zahlungspflichtig aktualisieren". Nach Bestätigung verschwinden die Buttons und es erscheint ein grüner Erfolgshinweis; jede spätere Mengen-/Produktänderung setzt den Block wieder auf den ungespeicherten Zustand.
- **Abo-Erhöhungsmodal (Nachtrag 2026-06-05):** Das bestehende Plus-Modal für dauerhafte Abo-Mengenerhöhungen wurde in `resources/views/admin/abo/_confirm_add_modal.blade.php` zentralisiert und rechtlich geschärft: Titel „Abo-Menge erhöhen", Info-Box, Artikel-/Mengenänderung, zusätzliche Kosten pro Lieferung, neuer Abo-Gesamtbetrag, Wirksamkeit zur nächsten Lieferung sowie AGB-/Widerrufs-Links. Primärer Button: „Zahlungspflichtig aktualisieren". Dieses Modal ist die aktuell umgesetzte aktive Zustimmung für dauerhafte Abo-Änderungen.
- **Gesamtsummen-Card (Nachtrag 2026-06-05):** `_order_combined_summary.blade.php` wurde optisch hervorgehoben: Primary-Header mit weißem Titel, heller Card-Body und weiße Endsumme-Zeile. Die Card bleibt die maßgebliche Stelle für Versand, Steuer und Gesamtsumme der kombinierten nächsten Lieferung.
- **Optimierung/Abweichung:** Statt `iq-modal-cart.js` zu überladen (Briefing 3.6) wurde ein **eigenes** JS-Modul gebaut sauberere Trennung, da Einmal-Artikel einen eigenen Endpoint, eigene Holder und eine eigene Re-Render-Logik haben; Risiko für den bestehenden Abo-Warenkorb wird vermieden.
- **Comp-Produkte (Nachtrag 2026-06-05):** Der Comp-Holder wird bei Einmal-AJAX-Aktionen mit aktualisiert; zusätzliche Kompensationsprodukte, die erst durch Einmal-/Zusatzprodukte nötig werden, erhalten einen sichtbaren Hinweis. Der Switch-Rebuild berücksichtigt Einmal-Artikel, damit eine Auswahl nicht mehr verschwindet.
- **Tests (Nachtrag 2026-06-05):** `tests/Feature/AboOneTimeServiceTest.php`, `tests/Feature/AboOneTimeViewTest.php` und `tests/Feature/CartMaxWeightTest.php` wurden um Confirmation-State, Split-Summen, Modal-Inhalte, Comp-Hinweis und Maximalgewicht erweitert. Zuletzt geprüft: `./vendor/bin/pint --dirty --format agent` und `php artisan test --compact tests/Feature/AboOneTimeViewTest.php` (8 Tests / 28 Assertions grün).
- **Offene Punkte für Phase 4/Folgearbeit:** Die technische Ausführung bleibt offen: Einmalprodukte in Cron/Retry übernehmen, `is_abo_addon` beim Schreiben der `ShoppingOrderItem` setzen, Purge nur nach erfolgreicher Zahlung, Rechnung/Lieferschein-Gruppierung und Reminder-Mail.
---
### Phase 4 Ausführung, Retry & Aufräumen · `OFFEN`
- [ ] 4.1 `UserMakeOrder::makeShoppingOrder()`: Einmal-Artikel mit in den Yard; beim Anlegen der `ShoppingOrderItem` `is_abo_addon=true` setzen; Zahlbetrag = kombiniert.
- [ ] 4.2 Zentrale Purge-Methode bei **Erfolg** aufrufen (Cron-Erfolg **und** `AboRetryPaymentService::markAboSuccess()`); bei Fehler **behalten** (Entscheidung #2/#3).
- [ ] 4.3 `user_abos.amount` entkoppeln reiner Abo-Betrag bleibt unverfälscht (Punkt 9.1).
- [ ] 4.4 User-seitiges Retry: Route + Action im Sales Center (`User\AboController`) und Portal (`Portal\AboController`) → `AboRetryPaymentService::retry()` mit Berechtigungs-Check; Admin-Retry bleibt bestehen.
- [ ] 4.5 num_comp bei Ausführung zurücksetzen (Einmal-Artikel raus → Kompensationsprodukte neu/zurück).
- [ ] 4.6 Tests: Cron setzt `is_abo_addon` korrekt; Purge nur bei Erfolg; Behalten bei Fehler; User-Retry inkl. Einmal-Artikel; `amount` unverfälscht.
**Doku:** _(nach Umsetzung ausfüllen)_
---
### Phase 5 Rechnung & Lieferschein · `OFFEN`
- [ ] 5.1 `invoice-detail.blade.php`: Positionen nach `is_abo_addon` gruppieren Abo oben, abgegrenzter Block „Einmalig hinzugefügte Artikel" darunter.
- [ ] 5.2 `delivery-detail.blade.php`: gleiche Gruppierung/Abgrenzung.
- [ ] 5.3 Übersetzungen DE/EN/ES/FR für Block-Überschriften.
- [ ] 5.4 Tests/Sichtprüfung: PDF mit gemischter Bestellung (Abo + Einmal) korrekt gruppiert.
**Doku:** _(nach Umsetzung ausfüllen)_
---
### Phase 6 Reminder-E-Mail · `OFFEN`
- [ ] 6.1 Mailable `MailAboExecutionReminder` + Template (DE/EN/ES/FR): Ausführung in X Tagen, Deckung prüfen, jetzt Produkte mitnehmen / Nachhaltigkeit.
- [ ] 6.2 Link je `is_for`: `me``getMyMivitaUrl()` + `user/abos/detail/me/{id}`; `ot``getMyMivitaPortalUrl()` + Portal-Abo-Route.
- [ ] 6.3 Command `abo:send-execution-reminder`: selektiert Abos mit `next_date = heute + abo-onetime-window-days`, `active`, `status=2`; Duplikat-Schutz pro Zyklus.
- [ ] 6.4 Schedule-Eintrag in `app/Console/Kernel.php`.
- [ ] 6.5 Tests: Selektion, Link-Wahl Berater/Endkunde, Duplikat-Schutz.
**Doku:** _(nach Umsetzung ausfüllen)_
---
### Phase 7 Gesamttests & Abschluss · `OFFEN`
- [ ] 7.1 End-to-End-Feature-Tests über alle Phasen.
- [ ] 7.2 `./vendor/bin/pint` über alle geänderten Dateien.
- [ ] 7.3 Vollständige Testsuite (nach Rücksprache) laufen lassen.
- [ ] 7.4 Abschluss-Dokumentation in diesem File aktualisieren.
**Doku:** _(nach Umsetzung ausfüllen)_
---
## 7. Datenmodell-Detail (Referenz)
`user_abo_one_time_items` (Snapshot-Felder wie `shopping_order_items`, vgl. `database/migrations/2019_02_23_163724_create_shopping_order_items_table.php`):
```
id BIGINT/INT PK
user_abo_id INT FK -> user_abos (cascade)
product_id INT FK -> products
comp TINYINT nullable (auto. Kompensationsprodukte)
qty INT
price DECIMAL(8,2) nullable (Snapshot)
price_net DECIMAL(8,3) nullable (Snapshot)
tax_rate DECIMAL(5,2) nullable (Snapshot)
tax DECIMAL(8,3) nullable (Snapshot)
price_vk_net DECIMAL(8,3) nullable (Snapshot)
discount DECIMAL(5,2) nullable (Snapshot)
points INT nullable (Snapshot)
status TINYINT default 0
created_at / updated_at / deleted_at
```
---
## 8. Test-Strategie
- **Pest Feature-Tests** als Schwerpunkt (Boost-/Projekt-Regeln). Pro Phase mindestens Happy-Path + relevante Fehler-/Grenzpfade.
- Factories für `UserAbo`, `UserAboItem`, `UserAboOneTimeItem`, `Product` (mit `show_on`, `weight`).
- Payone/Zahlung in Ausführungs-/Retry-Tests **mocken**.
- Zeit-/Datums-Tests mit `Carbon::setTestNow()` für das Fenster.
---
## 9. Zu beachtende Punkte / Ungereimtheiten / Optimierungen
> Diese Punkte sind **verbindlich** zu berücksichtigen und im jeweiligen Phasen-Doku-Abschnitt zu dokumentieren.
**9.1 `user_abos.amount` wird in `AboOrderCart::makeOrderYard()` bei jedem Aufruf überschrieben.**
Beim Einbinden der Einmal-Artikel in denselben Yard würde `amount` verfälscht. → `amount` weiterhin nur aus reinen Abo-Items; kombinierter Betrag separat für die Abbuchung berechnen (Architektur 4.4).
**9.2 Konflikt mit Lock-Fristen in `AboRepository`.**
`LOCK_DAYS_CHANGE = 10` / `LOCK_DAYS_PAUSE_CANCEL = 3` (hartkodiert) sperren Abo-Änderungen im Zeitfenster. Einmal-Artikel sind eine **eigene** Aktion und dürfen davon **nicht** betroffen sein → eigener Guard nur über `abo-onetime-window-days`.
**9.3 Kompensationsprodukte (`num_comp`) sind gewichts-/versandabhängig.**
Mehr Gewicht durch Einmal-Artikel kann ein zusätzliches Kompensationsprodukt nötig machen (Entscheidung #7). Berechnung muss Gesamtgewicht einbeziehen **und** bei nächster Ausführung zurückgesetzt werden.
**9.4 Rechnung folgt asynchron über Payone-Callback, nicht im Cron.**
Da `is_abo_addon` bereits beim Anlegen der `shopping_order_items` gesetzt wird und die Snapshot-Daten dauerhaft in der `ShoppingOrderItem` liegen, bleibt die Rechnung korrekt auch nachdem `user_abo_one_time_items` gelöscht wurden (Reihenfolge Purge vs. Rechnung unkritisch).
**9.5 Cart-Option `abo_addon` zusätzlich zum DB-Flag.**
Für getrennte Zwischensummen in der Live-Ansicht ohne DB-Roundtrip.
**9.6 Versandkostenfrei-Logik bleibt gültig** (`no_free_shipping`, `free_shipping_consultant`) Einmal-Artikel verhalten sich wie normale Bestellpositionen (Entscheidung #7).
**9.7 Test-Umgebung: `trans_languages`-Migration (in Phase 1 gefixt).**
Migration `2026_05_12_113939_add_french_locale_to_trans_languages.php` setzte eine nur in MySQL existierende Tabelle voraus → jeder RefreshDatabase-Test (SQLite `:memory:`) schlug fehl. Behoben durch `Schema::hasTable()`-Guard. Produktionssicher. Falls künftig weitere Migrationen Daten in nicht per Migration erzeugte Tabellen schreiben, gleiches Muster anwenden.
**9.8 Eigene Warenkorb-Instanz für den Abo-Flow (`AboOrderCart::INSTANCE = 'abo'`).**
Zuvor nutzten der reguläre Bestell-Flow (`OrderController`) **und** der Abo-Detail-/Bearbeitungs-Flow (`AboOrderCart`) dieselbe Session-Instanz `Yard::instance('shopping')`. Bei zwei offenen Fenstern (Abo + Bestellung) vermischten sich die Warenkörbe, da der Cart pro Session nur unter dem Instanz-Namen gespeichert wird. → Abo-Flow auf dedizierte Instanz `'abo'` umgestellt (getrennt von `'shopping'` und der Abo-Anlage-Instanz `'subscription'`). Betroffen: `AboOrderCart` (inkl. `UserService::setInstance`), Cron `UserMakeOrder`, `User\AboController`/`Portal\AboController` (Datatables + `comp_product`-Render via `cart_instance`), `UserAboItem::getPrice()`, alle Abo-Views/Partials (`detail`, `my_abo`, `admin/abo/detail`, `_order_abo_show`, `_order_combined_summary`) sowie das geteilte Partial `comp_product` (jetzt mit `$cart_instance`-Parameter, Default `'shopping'`). `Yard::setTax()` arbeitet auf der zuletzt via `instance()` gesetzten Instanz und folgt der Umstellung automatisch.
**9.9 Versandkosten-Anzeige bei steuerbefreiten Kunden (Anzeige-Konsistenz).**
In den Abo-Übersichten (`_order_abo_show`, `_order_combined_summary`) wurde die Versandzeile per `shipping()` (**brutto**) ausgegeben, obwohl steuerbefreite Kunden laut `Yard::totalWithShipping()` den **Netto**-Versand zahlen → Versandzeile stimmte nicht mit der (Netto-)Gesamtsumme überein. Fix: bei `getUserTaxFree()` jetzt `shippingNet()` anzeigen, sonst weiterhin `shipping()`. Der Bestell-Warenkorb (`yard_view_form`) war bereits korrekt (durchgängig netto). Abrechnung unverändert (steuerbefreit = Netto-Versand).
**9.10 Maximalgewicht-Sperre für alle Warenkörbe.**
Die Versandkosten sind in Gewichtsstufen (`shipping_prices.weight_from`/`weight_to`) definiert. Bisher fiel die Berechnung bei Überschreitung der höchsten Stufe auf `shipping_prices->first()` (= **günstigste** Stufe) zurück fachlich falsch. → Zwei Korrekturen:
1. **Fallback-Fix** in `Yard::calculateShippingPrice()`: bei Gewicht über der obersten Stufe wird nun die **teuerste** Stufe (`sortByDesc('weight_to')->first()`) genutzt statt der günstigsten.
2. **Zentrale Sperre** beim Hinzufügen/Hochsetzen: Neue Yard-Helfer `getMaxWeight()` (höchstes `weight_to` des Versandlandes) und `exceedsMaxWeight($additionalWeight)`. Würde eine Mengenerhöhung das Maximalgewicht überschreiten, wird das Produkt **nicht** hinzugefügt und ein Hinweis (`msg.cart_max_weight_reached`, DE/EN/FR/ES) angezeigt. Mengenreduktionen sind immer erlaubt.
Eingebaut in alle interaktiven Warenkörbe: regulärer Bestell-Warenkorb (`User\OrderController::handleUpdateCart`, Helfer `exceedsCartMaxWeight()`), Webshop (`Web\CardController` add/post/`updateCard`), Portal-Nachbestellung (`Portal\OrderController::addToCart`), Abo-Einmal-Artikel (`AboOneTimeService::add/update`) **und** permanente Abo-Artikel (`User\AboController`/`Portal\AboController` `addProduct`/`updateCart`). Für den Abo-Flow berechnet `AboOrderCart::combinedWeight()` das Gesamtgewicht aus regulären Abo- **und** Einmal-Artikeln; `AboOrderCart::exceedsMaxWeight()` prüft gegen das Maximalgewicht des (über `initYard()` gesetzten) Versandlandes. Programmatische Sammel-/Kombi-Bestellungen (Gewicht 0) bleiben unberührt.
**9.11 Berater-Bestell-Warenkorb: Plus/Minus-Buttons + Inline-Hinweis statt Alert-Popup.**
Der Bestell-Warenkorb (`yard_view_form`) nutzte zuvor ein Zahlen-Input mit Spinner; bei einer Mengenüberschreitung (z. B. Maximalgewicht) erschien ein `alert()`-Popup, **der Zähler lief jedoch weiter hoch**. → Angeglichen an den Abo-Stil: Mengensteuerung jetzt über ``/`+`-Buttons (`cart-remove-event`/`cart-add-event`, `input-group`/`input-extra`). Die ``/`+`-Handler in `iq-shopping-cart.js` (`change_cart_qty`) erhöhen die Anzeige **nicht** optimistisch, sondern erst nach Server-Bestätigung per Re-Render. Wird das Update serverseitig abgelehnt, liefert `OrderController::cartUpdateRejected()` den **unveränderten** Warenkorb (`response: true`, `html_card` mit aktueller Menge) plus `error_message` zurück → der Zähler bleibt stehen und der Hinweis erscheint als **Inline-Alert** (`#insert_show_error_message`) über dem Warenkorb (kein Popup). JS-Refresh (`showCartError`) ersetzt das frühere `alert()` durch dieselbe Inline-Meldung. Betrifft Gewichts- **und** Sortiments-Sperre (`cart_max_weight_reached`, `cart_product_not_allowed_for_order_type`).
**9.12 Kompensationsprodukte bei Versandstufenwechsel sauber synchronisieren.**
Wenn Einmal-Artikel oder reguläre Abo-Änderungen das Gesamtgewicht in eine andere Versandstufe verschieben, kann sich `num_comp` erhöhen oder wieder auf `0` fallen. Die alte Logik in `AboOrderCart::checkNumOfCompProducts()` legte durch eine Off-by-one-Schleife zu viele Comp-Produkte an und entfernte bestehende Comp-Produkte nicht, wenn die neue Versandstufe keine mehr benötigt. Fix: Es werden exakt fehlende Comp-Positionen `1..num_comp` angelegt, überschüssige Positionen konsequent aus DB und Yard entfernt, auch bei `num_comp = 0`. Zusätzlich wird im Sales-Center-Detail beim Laden offener Einmal-Artikel direkt `checkNumOfCompProducts()` ausgeführt (Portal hatte dies bereits), und AJAX-Re-Render respektiert wieder den tatsächlichen `add_only_mode`.
Nachtrag: Der normale Abo-Update-Endpunkt (`updateCompProduct`, reguläre Mengenänderungen) baut den Yard nun bei offenem Einmal-Fenster ebenfalls wieder **inkl. Einmal-Artikeln** auf, bevor `checkNumOfCompProducts()` läuft. Dadurch verschwindet ein nur durch Einmal-/Zusatzprodukte benötigtes Kompensationsprodukt beim Wechsel des Switches nicht mehr. Der Comp-Bereich wird als leerer Holder immer gerendert und bei Einmal-AJAX-Aktionen mit aktualisiert; Comp-Positionen oberhalb des reinen Abo-Bedarfs zeigen einen Hinweis, dass sie durch zusätzlich hinzugefügte Produkte nötig sind.
**9.13 Aktive Zustimmung bei dauerhaften Abo-Änderungen (umgesetzt über Modal).**
Dauerhafte Abo-Mengenerhöhungen werden über das zentrale Bestätigungsmodal `_confirm_add_modal.blade.php` abgesichert: Vor der Änderung sieht der User Titel, Hinweistext, Artikel-/Mengenänderung, zusätzliche Kosten, neuen Abo-Gesamtbetrag, Wirksamkeitsdatum und AGB-/Widerrufs-Links. Erst der Klick auf „Zahlungspflichtig aktualisieren" bestätigt die kostenpflichtige Änderung. Ein separates Pending-Datenmodell wird für diesen Stand **nicht** umgesetzt, da die aktive Zustimmung fachlich über das Modal erfolgt. Einmal-Artikel bleiben davon getrennt und haben ihren eigenen Confirm-/Discard-State.
---
## 10. Offene Entscheidung (zu klären, blockiert NICHT Phase 17)
**Automatisiertes Retry** (du erwähntest: „muss noch etwas mehr automatisiert werden"):
Der bestehende Retry ist Admin-manuell (`AboRetryPaymentService`). Eine **automatische** Wiederholung für `status=3`-Abos (geplanter Command, Intervall/Anzahl Versuche, Abbruchkriterien, Benachrichtigungen) ist sinnvoll, aber ein **eigenständiges Arbeitspaket**.
**Empfehlung:** Als separates Folge-Ticket/Phase außerhalb dieses Features umsetzen, um Scope und Risiko klein zu halten. **Entscheidung ausstehend** (in diesem Feature: nur **user-seitiges manuelles** Retry, Phase 4.4).
#Ja genau das ist ein eigenständiges Arbeitspaket. Erst mal nur den Retry wie in Phase 4.4
---
## 11. Änderungs- / Fortschritts-Log
| Datum | Phase | Beschreibung | Status |
| ----- | ----- | ------------ | ------ |
| 2026-06-05 | | Plan erstellt, Rahmenbedingungen geklärt | Plan finalisiert |
| 2026-06-05 | 1 | Datenmodell (`user_abo_one_time_items`, `is_abo_addon`), Model + Factory, Relation, Setting, `isOneTimeWindowOpen()`, 13 Tests grün; Nebenfix `trans_languages`-Migration-Guard | ERLEDIGT |
| 2026-06-05 | 2 | Backend: `addOneTimeItemsToYard`/`buildOneTimeSnapshot`/`getSplitSummary`, `AboOneTimeService`, `oneTime`-Endpoints (User+Portal) + Routen, `AboOneTimeItemRequest` + Übersetzungen; 10 Tests grün (23 gesamt) | ERLEDIGT |
| 2026-06-05 | 3 | Frontend: View-Partials (`_order_onetime`, `_order_onetime_show`, `_order_combined_summary`), `split_mode` in `_order_abo_show`, Onetime-Modal + `oneTimeDatatable` (User+Portal), 2-spaltiges Layout mit sticky Summen-Card (detail/my_abo), JS `iq-abo-onetime.js`, Modal-Branch `abo-add-onetime`, Routen, 10 Übersetzungs-Keys; 4 Render-Tests grün (27 gesamt) | ERLEDIGT |
| 2026-06-05 | Bugfix | Warenkorb-Trennung: Abo-Flow auf eigene Yard-Instanz `'abo'` (9.8), behebt Vermischung mit dem Bestell-Warenkorb über mehrere Fenster | ERLEDIGT |
| 2026-06-05 | Bugfix | Versandkosten-Anzeige bei steuerbefreit in Abo-Übersichten auf Netto angeglichen (9.9) | ERLEDIGT |
| 2026-06-05 | Bugfix | Maximalgewicht-Sperre für alle Warenkörbe + Fallback-Fix (teuerste statt günstigste Stufe) (9.10); `Yard::getMaxWeight()`/`exceedsMaxWeight()`, `AboOrderCart::combinedWeight()`/`exceedsMaxWeight()`, 4 Sprach-Keys; 17 Tests grün | ERLEDIGT |
| 2026-06-05 | Bugfix | Berater-Bestell-Warenkorb: ``/`+`-Buttons (Abo-Stil) statt Spinner, Inline-Hinweis statt Alert-Popup, Zähler läuft bei Stop nicht mehr hoch (9.11); `cartUpdateRejected()` + JS `change_cart_qty`/`showCartError` | ERLEDIGT |
| 2026-06-05 | Bugfix | Kompensationsprodukte bei Versandstufenwechsel korrigiert (9.12): exakte Anlage statt Off-by-one, Entfernen auch bei `num_comp=0`, CRM-Detail synchronisiert Comp-Produkte nach Einmal-Artikeln, AJAX-Re-Render respektiert `add_only_mode`; Nachtrag: Comp-Switch-Rebuild berücksichtigt Einmal-Artikel, Comp-Hinweis für Zusatzprodukt-bedingte Positionen, Live-Render des Comp-Holders; 37 Tests grün | ERLEDIGT |
| 2026-06-05 | UI/Consent | Rechtssicheres Modal für dauerhafte Abo-Mengenerhöhungen zentralisiert (`_confirm_add_modal`): Artikel-/Mengenänderung, Zusatzkosten, neuer Gesamtbetrag, Lieferdatum, AGB/Widerruf, Button „Zahlungspflichtig aktualisieren" (9.13) | ERLEDIGT |
| 2026-06-05 | UI/Onetime | Confirm-/Discard-State für Einmalprodukte: `confirmed_qty`/`confirmed_at`, Aktionen `confirm`/`discard`, rechtliche Bestätigungsfläche, Erfolgshinweis nach Speichern und Rücksprung auf ungespeichert bei späteren Änderungen | ERLEDIGT |
| 2026-06-05 | UI/Summen | Abo-Split-Liste zeigt neben der Abo-Zwischensumme auch die Endsumme der nächsten Lieferung; kombinierte Summen-Card optisch hervorgehoben; letzte Prüfung: 8 View-Tests / 28 Assertions grün | ERLEDIGT |