Abo Einmalprodukte: Phasen 5-7, konfigurierbare Sperrfristen, Lokalisierung & Dashboard

Rechnung/Lieferschein:
- PDF-Gruppierung Abo- vs. Einmal-Artikel (is_abo_addon), rueckwaertskompatibel
- Vorschau-Command sales:preview-invoice (umgeht Test-System-Gate, ohne Rechnungsnummer)

Reminder-Mail (vorbereitet, Cron inaktiv):
- MailAboExecutionReminder + Template, abo:send-execution-reminder, Spalte onetime_reminder_sent_for
- Berater -> Sales Center, Kunde -> Portal; Vorschau abo:preview-reminder
- Datum-Fix: formatDateDB() statt formatDate() (ICU-Format)

Sperrfristen konfigurierbar (Admin-Settings):
- abo-lock-days-change (Liefertag + Abo-Produkte), abo-lock-days-pause-cancel
- Meldungen mit :lock-Platzhalter; Pausieren/Kuendigen vom Change-Lock entkoppelt
- Abo-Produkt-Aenderungen im Lock-Fenster server- + clientseitig eingefroren
- Selbstbedienung: Add-only/Lock nach $view (kein Admin-Bypass), Stale-Cache-Fix beim Mengen-Update

Dashboard:
- Einmalprodukte-Card + Team-Abo-KPI-Card, News-Card responsiv (col-xl-6)

Lokalisierung:
- Sprachdateien en/es/fr vollstaendig mit de synchronisiert (inkl. marketingplan/portal)
- LocalizationSyncTest sichert Key-Gleichheit ab

Tests: AboOneTimeInvoiceGrouping, AboExecutionReminder, AboLockDaysSetting,
AboOnetimeDashboardAndMail, PreviewInvoiceCommand, TeamAboOverview + erweiterte View-Tests

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-06-23 13:38:11 +00:00
parent 9f996a7f1b
commit 65b867f8ee
96 changed files with 4154 additions and 1189 deletions

View file

@ -262,37 +262,62 @@ Neuer Slug **`abo-onetime-window-days`** (Typ `int`, Default `4`) in der „Abo
---
### Phase 5 – Rechnung & Lieferschein · `OFFEN`
### Phase 5 – Rechnung & Lieferschein · `ERLEDIGT (2026-06-12)`
- [ ] 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.
- [x] 5.1 `invoice-detail.blade.php`: Positionen nach `is_abo_addon` gruppieren – Abo oben, abgegrenzter Block „Einmalig hinzugefügte Artikel" darunter.
- [x] 5.2 `delivery-detail.blade.php`: gleiche Gruppierung/Abgrenzung.
- [x] 5.3 Übersetzungen DE/EN/ES/FR für Block-Überschriften.
- [x] 5.4 Tests/Sichtprüfung: PDF mit gemischter Bestellung (Abo + Einmal) korrekt gruppiert.
**Doku:** _(nach Umsetzung ausfüllen)_
**Doku (2026-06-12):**
- **5.1 + 5.2 Gruppierung (rückwärtskompatibel):** In beiden PDF-Templates wird die Positionsliste vor der Ausgabe per `sortBy(is_abo_addon ? 1 : 0)` stabil sortiert (Abo-Items zuerst, Einmal-Artikel danach – PHP 8.4 `sortBy` ist stabil, Reihenfolge innerhalb der Gruppen bleibt erhalten). Zwei Überschriften-Zeilen (`<tr>` mit `colspan` + grauem Hintergrund `#f2f2f2`) werden **nur dann** eingefügt, wenn die Bestellung tatsächlich Einmal-Artikel enthält (`$hasAddonItems`). Bestellungen ohne Einmal-Artikel (= alle regulären Bestellungen/Abos) rendern damit **unverändert** ohne zusätzliche Überschriften. `colspan` = 6 bei `payment_for === 6` (Kunde-Shop), sonst 7 (Berater); Lieferschein fest 3.
- **5.3 Übersetzungen:** Neue Keys `pdf.abo_items_heading` („Abo-Produkte") und `pdf.abo_addon_items_heading` („Einmalig hinzugefügte Artikel") in `resources/lang/{de,en,es,fr}/pdf.php`.
- **5.4 Tests:** `tests/Feature/AboOneTimeInvoiceGroupingTest.php` (4 Tests grün): Rechnung **und** Lieferschein zeigen bei gemischter Bestellung beide Block-Überschriften und Abo-Block **vor** Einmal-Block (`strpos`-Reihenfolge); reine Bestellung ohne Einmal-Artikel zeigt **keine** Überschriften (Backward-Compat-Beleg). Gesamt-Suite Einmalprodukte: **52 grün** (+ 1 erwartet rot = Review-Gate, s. Phase 4).
- **Snapshot-Sicherheit (vgl. 9.4):** Da `is_abo_addon` und alle Preis-Snapshots dauerhaft in `shopping_order_items` liegen, bleibt die Gruppierung korrekt, auch nachdem `user_abo_one_time_items` per Purge gelöscht wurden.
- **Pint:** ausgeführt (`--dirty`), pass.
---
### Phase 6 – Reminder-E-Mail · `OFFEN`
### Phase 6 – Reminder-E-Mail · `ERLEDIGT (2026-06-12)`
- [ ] 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.
- [x] 6.1 Mailable `MailAboExecutionReminder` + Template (DE/EN/ES/FR): Ausführung in X Tagen, Deckung prüfen, jetzt Produkte mitnehmen / Nachhaltigkeit.
- [x] 6.2 Link je `is_for`: `me` → `getMyMivitaUrl()` + `user/abos/detail/me/{id}`; `ot` → `getMyMivitaPortalUrl()` + Portal-Abo-Route.
- [x] 6.3 Command `abo:send-execution-reminder`: selektiert Abos mit `next_date = heute + abo-onetime-window-days`, `active`, `status=2`; Duplikat-Schutz pro Zyklus.
- [x] 6.4 Schedule-Eintrag in `app/Console/Kernel.php` (**bewusst inaktiv/auskommentiert** – Review-Gate).
- [x] 6.5 Tests: Selektion, Link-Wahl Berater/Endkunde, Duplikat-Schutz.
**Doku:** _(nach Umsetzung ausfüllen)_
**Doku (2026-06-12):**
- **6.1 Mailable** `app/Mail/MailAboExecutionReminder.php` (Constructor-Promotion `public UserAbo $userAbo`): ermittelt Anrede aus `shopping_user->billing_firstname`, Tage bis Ausführung (`diffInDays`, min 0), Liefer­datum (`Util::formatDate()`) und den zielgruppenspezifischen Link. Subject `email.abo_reminder_subject`. **Hinweis:** Das ererbte `Mailable::$subject` darf **nicht** typisiert redeklariert werden (Fatal Error) → Subject wird in `build()` via `->subject(__())` gesetzt.
- **Template** `resources/views/emails/abo_execution_reminder.blade.php` (am DHL-Tracking-Stil orientiert): Logo, Anrede, `trans_choice('email.abo_reminder_intro', $days, [...])` (Singular/Plural Tage + Datum), Einmalprodukte-Hinweis, hervorgehobene Box mit **Deckungs-Hinweis** + **Nachhaltigkeits-Hinweis** und CTA-Button (Text je `isConsultant`), Standard-Footer.
- **6.2 Link-Wahl** (`MailAboExecutionReminder::buildLink()`): Berater (`is_for === 'me'`) → `Util::getMyMivitaUrl()` + `/user/abos/detail/me/{id}` (Sales Center my.mivita.care); Endkunde (`ot`) → `Util::getMyMivitaPortalUrl()` + `/portal/my-subscriptions` (Kundenportal in.mivita.care). Cross-Domain → bewusst String-Konkatenation statt `route()`.
- **6.3 Command** `app/Console/Commands/AboSendExecutionReminder.php` (`abo:send-execution-reminder`, Option `--dry-run`): selektiert `active=true`, `status=2`, `is_for ∈ {me,ot}`, `next_date = heute + abo-onetime-window-days`. **Duplikat-Schutz:** neue Spalte `user_abos.onetime_reminder_sent_for` (date) – versendet nur, wenn `null` **oder** `!= next_date`; nach Erfolg wird sie auf `next_date` gesetzt. Empfänger-E-Mail: `abo->email` (Fallback `user->email` bzw. `shopping_user->billing_email`). Locale je Empfänger (`getLocale()`, Fallback `de`). Versandfehler → Log-Channel `abo_order`, kein Abbruch.
- **Migration** `database/migrations/2026_06_12_124917_add_onetime_reminder_sent_for_to_user_abos_table.php` – `date onetime_reminder_sent_for nullable after('next_date')`; `UserAbo` um Fillable + Cast `date` ergänzt. **Hinweis:** Migration muss beim Deploy auf der echten DB ausgeführt werden (`php artisan migrate`).
- **6.4 Schedule** in `app/Console/Kernel.php` als Kommentar hinterlegt (`// $schedule->command('abo:send-execution-reminder')->dailyAt('06:00');`) – **bewusst inaktiv**, bis das Review-Gate fällt (sonst würden Empfänger auf ein für sie ausgeblendetes Feature verlinkt). Nach Freigabe nur einkommentieren.
- **Übersetzungen** (`{de,en,es,fr}/email.php`): `abo_reminder_subject`, `abo_reminder_title`, `abo_reminder_intro` (pluralisiert), `abo_reminder_onetime_hint`, `abo_reminder_coverage_hint`, `abo_reminder_sustainability_hint`, `abo_reminder_button_consultant`, `abo_reminder_button_customer`.
- **6.5 Tests** `tests/Feature/AboExecutionReminderTest.php` (6 Tests grün, `Mail::fake()`): Selektion im Fenster, kein Versand außerhalb/inaktiv/falscher Status, Link-Wahl Berater (Sales Center) vs. Endkunde (Portal) über `->render()`, Duplikat-Schutz (zweiter Lauf versendet nicht, `onetime_reminder_sent_for` gesetzt). Gesamt Einmalprodukte: **58 grün** (+ 1 erwartet rot = Review-Gate).
- **Pint:** ausgeführt (`--dirty`), pass.
---
### Phase 7 – Gesamttests & Abschluss · `OFFEN`
### Phase 7 – Gesamttests & Abschluss · `ERLEDIGT (2026-06-12)`
- [ ] 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.
- [x] 7.1 End-to-End-Feature-Tests über alle Phasen (je Phase eigene Test-Datei, s. u.).
- [x] 7.2 `./vendor/bin/pint` über alle geänderten Dateien (pass).
- [x] 7.3 Vollständige Testsuite (nach Rücksprache) laufen lassen.
- [x] 7.4 Abschluss-Dokumentation in diesem File aktualisiert.
**Doku:** _(nach Umsetzung ausfüllen)_
**Doku (2026-06-12):**
- **Feature-Tests Einmalprodukte (alle grün):** `AboOneTimeWindowTest`, `AboOneTimeServiceTest`, `AboOneTimeViewTest`, `AboMakeOrderOneTimeTest`, `AboUserRetryTest`, `AboOneTimeInvoiceGroupingTest` (Phase 5), `AboExecutionReminderTest` (Phase 6) → **58 grün** (+ 1 erwartet rot = Review-Gate `isOneTimeFeatureVisible`).
- **Vollständige Suite (`php artisan test`):** 459 passed, 1 skipped, **93 failed** – Dauer ~97 s. **Alle 93 Fehler sind vorbestehend und NICHT durch Phase 5–7 verursacht** (verifiziert):
- `Unit/Services/InvoiceServiceTest` → `no such table: settings` (Test nutzt reale DB-Tabelle, fehlt auf SQLite).
- `Feature/ExampleTest` → `no such table: dbip_lookup` (Geo-IP-Tabelle nur in MySQL).
- `Feature/Payment/ConcurrentPaymentTest` + `PayoneRaceConditionTest` → `QueryException` (SQLite kann die Concurrency-/Lock-Szenarien nicht abbilden; gehören zum Payment-Branch).
- `Unit/BusinessPlan/*`, `Feature/BusinessPlan/*`, `Unit/Incentive/*`, `Feature/Incentive/*` → `BadMethodCallException`/`RouteNotFoundException`/`TypeError`/`ReflectionException` (unabhängige Altlasten).
- `Feature/FrenchLocalizationTest` → `fr/abo.php` fehlen Keys, die `de/abo.php` hat (`abo_extern_done`, `abo_finish`, `abo_order_info_*`, Status-Typen). **Bereits auf committed HEAD rot** (Beleg: `git show HEAD:resources/lang/fr/abo.php` enthält `abo_extern_done` nicht, `de` schon). Stammt aus **fremden** Commits (u. a. „Bestellung extern als erledigt markieren"), nicht aus diesem Feature. Die Einmalprodukte-Keys selbst sind in allen vier Sprachen (de/en/es/fr) vollständig vorhanden; die in Phase 5/6 ergänzten `pdf.php`/`email.php`-Keys wurden in allen vier Sprachen synchron hinzugefügt.
- **Offener Fremd-Befund (nicht Teil dieses Features):** `fr/abo.php` und `es/abo.php` sind gegenüber `de/abo.php` nicht synchron (fehlende Keys aus anderen Arbeitspaketen). Empfehlung: in einem eigenen Ticket nachziehen.
---
@ -408,4 +433,9 @@ Der bestehende Retry ist Admin-manuell (`AboRetryPaymentService`). Eine **automa
| 2026-06-08 | Review-Gate | **Temporäre Live-Sichtbarkeit für Abstimmung:** Einmalprodukte-Feature über `AboHelper::isOneTimeFeatureVisible()` nur für VIP-User (`isVIP()` = `admin>=1`) im Sales Center sichtbar; Endkunden-Portal (customers-Guard, kein VIP) komplett ausgeblendet. Gates in `User\AboController` (detail/update/oneTime/oneTimeDatatable), `Portal\AboController` (myAbo/update/oneTime/oneTimeDatatable/modalLoad), `ModalController` (abo-add-onetime). Reversibel: Methode später auf reines `isOneTimeWindowOpen()` zurücksetzen + Portal reaktivieren. 4 neue Tests (47 gesamt grün) | ERLEDIGT |
| 2026-06-08 | 4 (Schritt 1) | **Ausführung – Einmal-Artikel in die Bestellung:** `UserMakeOrder::makeShoppingOrder()` lädt nach `makeOrderYard()` zusätzlich `AboOrderCart::addOneTimeItemsToYard()` (nur verbindlich bestätigte Artikel); `ShoppingOrderItem` erhält `is_abo_addon` aus `options->abo_addon`. Reihenfolge sichert: `user_abos.amount` bleibt der **reine** Abo-Betrag, der Abbuchungsbetrag der Bestellung ist kombiniert (Abo + Einmal). 3 neue Tests (`AboMakeOrderOneTimeTest`) | ERLEDIGT |
| 2026-06-08 | 4 (Schritt 2) | **Purge & Comp-Reset nur bei Erfolg:** Neue Methode `AboOneTimeService::purgeAfterExecution()` löscht nach erfolgreicher Ausführung **alle** Einmal-Artikel des Abos (bestätigt + offen + soft-deleted, `forceDelete`) und bewertet anschließend die Kompensationsprodukte des reinen Abos neu (Option A: `initYard`+`makeOrderYard`+`checkNumOfCompProducts`). Aufruf im Erfolgszweig von `UserMakeAboOrder::updateAbo()` (Cron) und `AboRetryPaymentService::markAboSuccess()`. Bei Zahlungsfehler bleiben die Einmal-Artikel erhalten. Fehler beim Purge rollen den erfolgreichen Lauf nicht zurück (nur Logging). 3 neue Tests (Purge inkl. soft-deleted, Comp-Reduktion, „behalten bei Fehler") | ERLEDIGT |
| 2026-06-12 | Review-Gate | **Review-Gate AUFGEHOBEN:** `AboHelper::isOneTimeFeatureVisible()` delegiert wieder rein auf `isOneTimeWindowOpen()` (fensterbasiert, Sales Center + Portal gleichermaßen). VIP-Beschränkung und Portal-Ausblendung entfernt, Fensterprüfung reaktiviert. VIP-Gate-Tests in `AboOneTimeWindowTest` auf fensterbasiertes Verhalten umgeschrieben → **58 grün, kein erwartet-roter Test mehr**. **Hinweis:** Reminder-Cron bleibt weiterhin inaktiv (separate Entscheidung). | ERLEDIGT |
| 2026-06-12 | Settings | **Sperrfristen konfigurierbar:** `AboRepository`-Konstanten → Settings `abo-lock-days-change` (Default 10, Abo bearbeiten) und `abo-lock-days-pause-cancel` (Default 3, Pausieren/Kündigen), zwei Felder in der Admin-Abo-Card. 4 neue Tests (`AboLockDaysSettingTest`). Keine Migration nötig (generisches Setting-K/V) | ERLEDIGT |
| 2026-06-12 | 7 | **Gesamttests & Abschluss:** Vollständige Suite gelaufen (459 passed, 1 skipped, 93 failed). Alle 93 Fehler vorbestehend/fremd (SQLite-Infra: `settings`/`dbip_lookup`-Tabellen; Payment-Concurrency; BusinessPlan/Incentive-Altlasten; `fr/abo.php`-Sync aus Fremd-Commits – bereits auf HEAD rot). Einmalprodukte-Feature: 58 grün + 1 erwartet rot (Review-Gate). Pint pass | ERLEDIGT |
| 2026-06-12 | 6 | **Reminder-E-Mail (vorbereitet, Cron inaktiv):** Mailable `MailAboExecutionReminder` + Template (DE/EN/ES/FR), Command `abo:send-execution-reminder` (Selektion `next_date = heute + Fenster`, `active`, `status=2`, Duplikat-Schutz über neue Spalte `user_abos.onetime_reminder_sent_for`). Link-Unterscheidung Berater → Sales Center (my.mivita), Endkunde → Portal (in.mivita). Schedule-Eintrag **bewusst auskommentiert** (Review-Gate). 6 neue Tests (`AboExecutionReminderTest`, 58 gesamt grün). **Deploy:** `php artisan migrate` nötig | ERLEDIGT |
| 2026-06-12 | 5 | **Rechnung & Lieferschein – Gruppierung:** `invoice-detail.blade.php` + `delivery-detail.blade.php` gruppieren Positionen nach `is_abo_addon` (Abo oben, abgegrenzter Block „Einmalig hinzugefügte Artikel" darunter) – nur bei tatsächlich vorhandenen Einmal-Artikeln, sonst unveränderte Ausgabe. Neue Lang-Keys `pdf.abo_items_heading`/`pdf.abo_addon_items_heading` (DE/EN/ES/FR). 4 neue Tests (`AboOneTimeInvoiceGroupingTest`, 52 gesamt grün) | ERLEDIGT |
| 2026-06-08 | 4 (Schritt 3) | **User-Retry (Sales Center + Portal):** Neue Methoden `User\AboController::retryPayment($view,$id)` (Permission via `checkPermissions`) und `Portal\AboController::retryPayment($id)` (Permission via `checkPortalPermission`), delegieren an `AboRetryPaymentService::retry()` (nutzt denselben `makeShoppingOrder`-Pfad → Einmal-Artikel automatisch enthalten). Routen `user_abos_retry_payment` (CRM) und `portal_abos_retry_payment` (Portal). Gemeinsames Confirm-Modal `_retry_payment_modal` (nur bei `status===3 && active`) in `user/abo/detail` und `portal/abo/my_abo` inkl. Erfolgsmeldung. 2 neue Tests (Erfolg + 403 bei fremdem Abo). **Hinweis:** Ein bestehender Test (`isOneTimeFeatureVisible außerhalb des Fensters → false`) schlägt erwartungsgemäß fehl, da die Fensterprüfung für die Review-Phase bewusst auskommentiert bleibt | ERLEDIGT |