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

@ -0,0 +1,110 @@
<?php
use App\Models\ShoppingOrder;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(Tests\TestCase::class, RefreshDatabase::class);
/**
* Phase 5 – Rechnung & Lieferschein: Positionen werden nach `is_abo_addon`
* gruppiert (Abo-Produkte oben, abgegrenzter Block „Einmalig hinzugefügte
* Artikel" darunter). Bestellungen ohne Einmal-Artikel bleiben unverändert.
*/
beforeEach(fn () => makeShopEnv());
function makeMixedOrder(bool $withAddon = true): ShoppingOrder
{
$order = ShoppingOrder::create([
'shopping_user_id' => 1,
'auth_user_id' => 1,
'country_id' => 1,
'user_shop_id' => 0,
'payment_for' => 6,
'shipping' => 0,
'shipping_net' => 0,
'subtotal_ws' => 100,
'tax' => 0,
'total_shipping' => 100,
'is_abo' => true,
]);
$aboProduct = makeProduct(['12'], 50, 0);
$order->shopping_order_items()->create([
'product_id' => $aboProduct->id,
'is_abo_addon' => false,
'qty' => 1,
'price' => 50,
'price_net' => 50,
'tax_rate' => 0,
'tax' => 0,
'price_vk_net' => 50,
'discount' => 0,
'points' => 0,
]);
if ($withAddon) {
$addonProduct = makeProduct(['2'], 30, 0);
$order->shopping_order_items()->create([
'product_id' => $addonProduct->id,
'is_abo_addon' => true,
'qty' => 1,
'price' => 30,
'price_net' => 30,
'tax_rate' => 0,
'tax' => 0,
'price_vk_net' => 30,
'discount' => 0,
'points' => 0,
]);
}
return $order->fresh('shopping_order_items.product');
}
describe('Rechnung-PDF Gruppierung', function () {
it('zeigt bei gemischter Bestellung beide Block-Überschriften, Abo vor Einmal', function () {
$order = makeMixedOrder();
$html = view('pdf.invoice-detail', ['shopping_order' => $order])->render();
expect($html)->toContain(__('pdf.abo_items_heading'))
->and($html)->toContain(__('pdf.abo_addon_items_heading'));
$aboPos = strpos($html, __('pdf.abo_items_heading'));
$addonPos = strpos($html, __('pdf.abo_addon_items_heading'));
expect($aboPos)->toBeLessThan($addonPos);
});
it('zeigt KEINE Block-Überschriften bei reiner Bestellung ohne Einmal-Artikel', function () {
$order = makeMixedOrder(withAddon: false);
$html = view('pdf.invoice-detail', ['shopping_order' => $order])->render();
expect($html)->not->toContain(__('pdf.abo_addon_items_heading'))
->and($html)->not->toContain(__('pdf.abo_items_heading'));
});
});
describe('Lieferschein-PDF Gruppierung', function () {
it('zeigt bei gemischter Bestellung beide Block-Überschriften, Abo vor Einmal', function () {
$order = makeMixedOrder();
$html = view('pdf.delivery-detail', ['shopping_order' => $order])->render();
expect($html)->toContain(__('pdf.abo_items_heading'))
->and($html)->toContain(__('pdf.abo_addon_items_heading'));
$aboPos = strpos($html, __('pdf.abo_items_heading'));
$addonPos = strpos($html, __('pdf.abo_addon_items_heading'));
expect($aboPos)->toBeLessThan($addonPos);
});
it('zeigt KEINE Block-Überschriften bei reiner Bestellung ohne Einmal-Artikel', function () {
$order = makeMixedOrder(withAddon: false);
$html = view('pdf.delivery-detail', ['shopping_order' => $order])->render();
expect($html)->not->toContain(__('pdf.abo_addon_items_heading'))
->and($html)->not->toContain(__('pdf.abo_items_heading'));
});
});