Feedback-Umsetzung (dev/2026-07-22/feedback-analyse-2026-07-24.md, alle 10 Punkte):
- VIP-Statistik: eigener Berater ('ich', Linie 0) in Neue-Kundenabos- und beiden Top-10-Listen; Top-10-Umsatz sortiert nach abgerechneten Ist-Punkten + Abo-Anzahl-Spalte; Fußnote nicht-payline-wirksame Punkte (SalesVolumePoints::nonPaylinePoints)
- Führungskräfte-Liste: beide Sichten 'direkt' (an nächster FK abgeschnitten, additiv) und 'gesamt' (komplette Downline), Gesamt-Spalte nach Kundenzählweise
- Team-Abos: je Ebene 'abgerechnet (Monat)' und 'bis zum 25. erwartet'; Payline-Karte mit Teamabo-/Kundenabo-Zählern
- Incentive-Live-Ranking: Qualifizierte zuerst nummeriert (fixt auch isWinner rank<=max_winners)
- Porto: Netto-/Brutto-Kennzeichnung (Berater netto, Endkunden inkl. MwSt., portal_view-Flag; auch CRM-/Shop-Warenkorb + Checkout), Yard::shipping_tax-Fix
- Struktur-Ansicht: Abo-Badges 'A' (eigenes Abo) und '(n)' (Kundenabos) je Teampartner, gebatcht
- Abo-Löschung: Softdelete setzt active=0 (UserAbo deleting-Hook) + Bereinigungs-Migration für gelöschte-aber-aktive Abos
- Doku: technische Analyse + Kundinnen-Feedback-Dokument unter dev/2026-07-22/
- Tests: IncentiveRankingTest, TeamStructureAboBadgesTest, YardShippingTaxTest (neu); BackofficeStatisticsListsTest, TeamAboOverviewTest, PaylineForecastServiceTest, AboAdminDeleteTest, AboOneTimeViewTest erweitert
Außerdem enthalten (Branch-WIP vor dem Feedback): DATEV-Abgleich-Fixes 08+09/2025, USt-ID-Live-Validierung sowie restliche unkommittete Stände (u. a. Abo-Pause-Sprachkeys).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
357 lines
14 KiB
PHP
357 lines
14 KiB
PHP
<?php
|
||
|
||
use App\Models\UserAboItem;
|
||
use App\Models\UserAboOneTimeItem;
|
||
use App\Services\AboOrderCart;
|
||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||
use Tests\TestCase;
|
||
|
||
uses(TestCase::class, RefreshDatabase::class);
|
||
|
||
describe('Abo Einmal-Produkte Views', function () {
|
||
beforeEach(fn () => makeShopEnv());
|
||
|
||
it('rendert die Einmal-Produktliste mit Position und offener Zwischensumme', function () {
|
||
$abo = makeMeAbo();
|
||
$product = makeProduct(['2'], 119, 19);
|
||
|
||
UserAboOneTimeItem::create([
|
||
'user_abo_id' => $abo->id,
|
||
'product_id' => $product->id,
|
||
'qty' => 2,
|
||
'price' => 119.0,
|
||
'price_net' => 100.0,
|
||
'tax_rate' => 19.0,
|
||
'tax' => 19.0,
|
||
'status' => 0,
|
||
]);
|
||
|
||
$summary = ['one_time' => ['gross' => 238.0, 'net' => 200.0, 'tax' => 38.0]];
|
||
|
||
$html = view('admin.abo._order_onetime_show', [
|
||
'user_abo' => $abo->fresh(),
|
||
'summary' => $summary,
|
||
])->render();
|
||
|
||
expect($html)->toContain($product->getLang('name'))
|
||
->and($html)->toContain(__('abo.onetime_pending_subtotal'))
|
||
->and($html)->toContain(__('abo.onetime_status_pending'))
|
||
->and($html)->toContain('238,00');
|
||
});
|
||
|
||
it('zeigt bestätigte und offene Artikel mit getrennten Zwischensummen', function () {
|
||
$abo = makeMeAbo();
|
||
$confirmed = makeProduct(['2'], 119, 19);
|
||
$pending = makeProduct(['2'], 50, 19);
|
||
|
||
UserAboOneTimeItem::create([
|
||
'user_abo_id' => $abo->id,
|
||
'product_id' => $confirmed->id,
|
||
'qty' => 1,
|
||
'confirmed_qty' => 1,
|
||
'confirmed_at' => now(),
|
||
'price' => 119.0,
|
||
'tax_rate' => 19.0,
|
||
'status' => 1,
|
||
]);
|
||
UserAboOneTimeItem::create([
|
||
'user_abo_id' => $abo->id,
|
||
'product_id' => $pending->id,
|
||
'qty' => 1,
|
||
'price' => 50.0,
|
||
'tax_rate' => 19.0,
|
||
'status' => 0,
|
||
]);
|
||
|
||
$html = view('admin.abo._order_onetime_show', [
|
||
'user_abo' => $abo->fresh(),
|
||
'summary' => ['one_time' => ['gross' => 119.0], 'total_with_shipping' => 219.0],
|
||
])->render();
|
||
|
||
expect($html)->toContain(__('abo.onetime_confirmed_subtotal'))
|
||
->and($html)->toContain(__('abo.onetime_pending_subtotal'))
|
||
->and($html)->toContain(__('abo.onetime_new_total'))
|
||
->and($html)->toContain(__('abo.onetime_status_confirmed'))
|
||
->and($html)->toContain(__('abo.onetime_status_pending'))
|
||
->and($html)->toContain(__('abo.onetime_pending_hint'))
|
||
->and($html)->toContain('50,00')
|
||
->and($html)->toContain('169,00');
|
||
});
|
||
|
||
it('zeigt bei unbestätigten Einmal-Artikeln die Bestätigungsbuttons', function () {
|
||
$abo = makeMeAbo();
|
||
$product = makeProduct(['2'], 119, 19);
|
||
|
||
UserAboOneTimeItem::create([
|
||
'user_abo_id' => $abo->id,
|
||
'product_id' => $product->id,
|
||
'qty' => 1,
|
||
'price' => 119.0,
|
||
'price_net' => 100.0,
|
||
'tax_rate' => 19.0,
|
||
'tax' => 19.0,
|
||
'status' => 0,
|
||
]);
|
||
|
||
$html = view('admin.abo._order_onetime_show', [
|
||
'user_abo' => $abo->fresh(),
|
||
'summary' => ['one_time' => ['gross' => 119.0], 'total_with_shipping' => 219.0],
|
||
])->render();
|
||
|
||
expect($html)->toContain(__('abo.onetime_pending_subtotal'))
|
||
->and($html)->toContain(__('abo.onetime_legal_notice', [
|
||
'nextBillingDate' => $abo->next_date,
|
||
'agb' => '<a href="'.url('/agb').'" target="_blank">'.__('abo.confirm_terms_link').'</a>',
|
||
'withdrawal' => '<a href="'.asset('download/mivita_widerruf_formular.pdf').'" target="_blank">'.__('abo.confirm_withdrawal_link').'</a>',
|
||
]))
|
||
->and($html)->toContain(__('abo.onetime_discard_changes'))
|
||
->and($html)->toContain(__('abo.onetime_confirm_paid'));
|
||
});
|
||
|
||
it('zeigt bei bestätigten Einmal-Artikeln den Erfolgshinweis', function () {
|
||
$abo = makeMeAbo();
|
||
$product = makeProduct(['2'], 119, 19);
|
||
|
||
UserAboOneTimeItem::create([
|
||
'user_abo_id' => $abo->id,
|
||
'product_id' => $product->id,
|
||
'qty' => 1,
|
||
'confirmed_qty' => 1,
|
||
'confirmed_at' => now(),
|
||
'price' => 119.0,
|
||
'price_net' => 100.0,
|
||
'tax_rate' => 19.0,
|
||
'tax' => 19.0,
|
||
'status' => 1,
|
||
]);
|
||
|
||
$html = view('admin.abo._order_onetime_show', [
|
||
'user_abo' => $abo->fresh(),
|
||
'summary' => ['one_time' => ['gross' => 119.0], 'total_with_shipping' => 219.0],
|
||
])->render();
|
||
|
||
expect($html)->toContain(__('abo.onetime_confirm_success'))
|
||
->and($html)->toContain('data-confirmation-state="confirmed"')
|
||
->and($html)->not->toContain(__('abo.onetime_confirm_paid'));
|
||
});
|
||
|
||
it('zeigt nach einer Änderung an bestätigten Einmal-Artikeln wieder die Bestätigungsbuttons', function () {
|
||
$abo = makeMeAbo();
|
||
$product = makeProduct(['2'], 119, 19);
|
||
|
||
UserAboOneTimeItem::create([
|
||
'user_abo_id' => $abo->id,
|
||
'product_id' => $product->id,
|
||
'qty' => 3,
|
||
'confirmed_qty' => 1,
|
||
'confirmed_at' => now(),
|
||
'price' => 119.0,
|
||
'price_net' => 100.0,
|
||
'tax_rate' => 19.0,
|
||
'tax' => 19.0,
|
||
'status' => 0,
|
||
]);
|
||
|
||
$html = view('admin.abo._order_onetime_show', [
|
||
'user_abo' => $abo->fresh(),
|
||
'summary' => ['one_time' => ['gross' => 357.0], 'total_with_shipping' => 457.0],
|
||
])->render();
|
||
|
||
expect($html)->toContain('data-confirmation-state="changed"')
|
||
->and($html)->toContain(__('abo.onetime_discard_changes'))
|
||
->and($html)->toContain(__('abo.onetime_confirm_paid'))
|
||
->and($html)->not->toContain(__('abo.onetime_confirm_success'));
|
||
});
|
||
|
||
it('zeigt den Leer-Hinweis, wenn keine Einmal-Produkte vorhanden sind', function () {
|
||
$abo = makeMeAbo();
|
||
|
||
$html = view('admin.abo._order_onetime_show', [
|
||
'user_abo' => $abo,
|
||
'summary' => ['one_time' => ['gross' => 0]],
|
||
])->render();
|
||
|
||
expect($html)->toContain(__('abo.onetime_empty'));
|
||
});
|
||
|
||
it('rendert die kombinierte Gesamtsumme mit getrennten Zwischensummen', function () {
|
||
$yard = Yard::instance(AboOrderCart::INSTANCE);
|
||
$yard->destroy();
|
||
|
||
$aboRow = $yard->add(1, 'Abo-Produkt', 1, 119.0, false, false, ['weight' => 100]);
|
||
Yard::setTax($aboRow->rowId, 19.0);
|
||
$oneTimeRow = $yard->add(2, 'Einmal-Produkt', 1, 59.5, false, false, ['weight' => 100, 'abo_addon' => true]);
|
||
Yard::setTax($oneTimeRow->rowId, 19.0);
|
||
|
||
$summary = AboOrderCart::getSplitSummary();
|
||
|
||
$html = view('admin.abo._order_combined_summary', ['summary' => $summary])->render();
|
||
|
||
expect($html)->toContain(__('abo.combined_summary_hl'))
|
||
->and($html)->toContain(__('abo.onetime_subtotal'))
|
||
->and($html)->toContain(__('abo.abo_subtotal'))
|
||
->and($html)->toContain(__('order.total_sum'));
|
||
|
||
$yard->destroy();
|
||
});
|
||
|
||
it('kennzeichnet den Versand in der Berater-Ansicht als netto und im Portal als brutto', function () {
|
||
$yard = Yard::instance(AboOrderCart::INSTANCE);
|
||
$yard->destroy();
|
||
$aboRow = $yard->add(1, 'Abo-Produkt', 1, 119.0, false, false, ['weight' => 100]);
|
||
Yard::setTax($aboRow->rowId, 19.0);
|
||
|
||
$summary = AboOrderCart::getSplitSummary();
|
||
|
||
$consultantHtml = view('admin.abo._order_combined_summary', ['summary' => $summary])->render();
|
||
$portalHtml = view('admin.abo._order_combined_summary', ['summary' => $summary, 'portal_view' => true])->render();
|
||
|
||
expect($consultantHtml)->toContain('('.__('order.net').')')
|
||
->and($consultantHtml)->not->toContain('('.__('order.incl_VAT').')')
|
||
->and($portalHtml)->toContain('('.__('order.incl_VAT').')')
|
||
->and($portalHtml)->not->toContain('('.__('order.net').')');
|
||
|
||
$yard->destroy();
|
||
});
|
||
|
||
it('kennzeichnet den Versand in der Abo-Artikelliste je nach Kontext als netto oder brutto', function () {
|
||
$abo = makeMeAbo();
|
||
|
||
$yard = Yard::instance(AboOrderCart::INSTANCE);
|
||
$yard->destroy();
|
||
$aboRow = $yard->add(1, 'Abo-Produkt', 1, 119.0, false, false, ['weight' => 100]);
|
||
Yard::setTax($aboRow->rowId, 19.0);
|
||
|
||
$base = [
|
||
'user_abo' => $abo,
|
||
'split_mode' => false,
|
||
'only_show_products' => false,
|
||
'add_only_mode' => false,
|
||
];
|
||
|
||
$consultantHtml = view('admin.abo._order_abo_show', $base)->render();
|
||
$portalHtml = view('admin.abo._order_abo_show', $base + ['portal_view' => true])->render();
|
||
|
||
expect($consultantHtml)->toContain('('.__('order.net').')')
|
||
->and($consultantHtml)->not->toContain('('.__('order.incl_VAT').')')
|
||
->and($portalHtml)->toContain('('.__('order.incl_VAT').')');
|
||
|
||
$yard->destroy();
|
||
});
|
||
|
||
it('rendert die Abo-Liste im Split-Modus mit Abo-Zwischensumme und Endsumme', function () {
|
||
$abo = makeMeAbo();
|
||
|
||
$yard = Yard::instance(AboOrderCart::INSTANCE);
|
||
$yard->destroy();
|
||
$aboRow = $yard->add(1, 'Abo-Produkt', 1, 119.0, false, false, ['weight' => 100]);
|
||
Yard::setTax($aboRow->rowId, 19.0);
|
||
|
||
$summary = AboOrderCart::getSplitSummary();
|
||
|
||
$html = view('admin.abo._order_abo_show', [
|
||
'user_abo' => $abo,
|
||
'split_mode' => true,
|
||
'summary' => $summary,
|
||
'only_show_products' => false,
|
||
'add_only_mode' => false,
|
||
])->render();
|
||
|
||
expect($html)->toContain(__('abo.abo_subtotal'))
|
||
->and($html)->toContain(__('abo.abo_next_delivery_total'))
|
||
->and($html)->not->toContain(__('order.shipping_costs'))
|
||
->and($html)->not->toContain(__('order.total_sum'));
|
||
|
||
$yard->destroy();
|
||
});
|
||
|
||
it('rendert den Bestätigungsdialog für kostenpflichtige Abo-Erhöhungen', function () {
|
||
$abo = makeMeAbo();
|
||
$abo->amount = 1760;
|
||
$abo->save();
|
||
|
||
$html = view('admin.abo._confirm_add_modal', [
|
||
'user_abo' => $abo,
|
||
])->render();
|
||
|
||
expect($html)->toContain(__('abo.confirm_increase_title'))
|
||
->and($html)->toContain(__('abo.confirm_increase_info'))
|
||
->and($html)->toContain(__('abo.confirm_new_total'))
|
||
->and($html)->toContain(__('abo.confirm_legal_notice', [
|
||
'nextBillingDate' => '<span id="confirm-add-next-billing-date"></span>',
|
||
'agb' => '<a href="'.url('/agb').'" target="_blank">'.__('abo.confirm_terms_link').'</a>',
|
||
'withdrawal' => '<a href="'.asset('download/mivita_widerruf_formular.pdf').'" target="_blank">'.__('abo.confirm_withdrawal_link').'</a>',
|
||
]))
|
||
->and($html)->toContain(__('abo.confirm_add_ok'));
|
||
});
|
||
|
||
it('rendert nach einer Mengenänderung die aktuelle (persistierte) Menge – kein Stale-Cache', function () {
|
||
$abo = makeMeAbo();
|
||
$product = makeProduct(['12'], 50, 0);
|
||
$item = UserAboItem::create([
|
||
'user_abo_id' => $abo->id, 'product_id' => $product->id, 'comp' => 0, 'qty' => 1, 'status' => 1,
|
||
]);
|
||
|
||
// Relation cachen (wie initYard/makeOrderYard im Controller)
|
||
$abo->load('user_abo_items');
|
||
expect($abo->user_abo_items->first()->qty)->toBe(1);
|
||
|
||
// Menge über separate Instanz erhöhen (wie der Controller in updateCart/addProduct)
|
||
UserAboItem::find($item->id)->update(['qty' => 3]);
|
||
|
||
// Fix: Relation vor dem Render neu laden → gerenderte Menge = gespeicherter Stand
|
||
$abo->load('user_abo_items');
|
||
$html = view('admin.abo._order_abo_show', [
|
||
'user_abo' => $abo,
|
||
'split_mode' => true,
|
||
'summary' => ['abo' => ['gross' => 150], 'total_with_shipping' => 150],
|
||
])->render();
|
||
|
||
expect($html)->toContain('value="3"');
|
||
});
|
||
|
||
it('blendet Minus/Entfernen im Add-only-Modus aus (Mindestlaufzeit nicht erfüllt)', function () {
|
||
$abo = makeMeAbo();
|
||
$product = makeProduct(['12'], 50, 0);
|
||
UserAboItem::create([
|
||
'user_abo_id' => $abo->id, 'product_id' => $product->id, 'comp' => 0, 'qty' => 2, 'status' => 1,
|
||
]);
|
||
$abo->load('user_abo_items');
|
||
|
||
$summary = ['abo' => ['gross' => 100], 'total_with_shipping' => 100];
|
||
|
||
$addOnly = view('admin.abo._order_abo_show', [
|
||
'user_abo' => $abo, 'split_mode' => true, 'summary' => $summary, 'add_only_mode' => true,
|
||
])->render();
|
||
expect($addOnly)->not->toContain('remove-from-basket')
|
||
->and($addOnly)->not->toContain('auto-delete-product')
|
||
->and($addOnly)->toContain('add-from-basket') // Hinzufügen bleibt möglich
|
||
->and($addOnly)->toContain('readonly');
|
||
|
||
$free = view('admin.abo._order_abo_show', [
|
||
'user_abo' => $abo, 'split_mode' => true, 'summary' => $summary, 'add_only_mode' => false,
|
||
])->render();
|
||
expect($free)->toContain('remove-from-basket')
|
||
->and($free)->toContain('auto-delete-product');
|
||
});
|
||
|
||
it('friert alle Produkt-Steuerungen ein, wenn der Change-Lock greift', function () {
|
||
$abo = makeMeAbo();
|
||
$product = makeProduct(['12'], 50, 0);
|
||
UserAboItem::create([
|
||
'user_abo_id' => $abo->id, 'product_id' => $product->id, 'comp' => 0, 'qty' => 2, 'status' => 1,
|
||
]);
|
||
$abo->load('user_abo_items');
|
||
|
||
$html = view('admin.abo._order_abo_show', [
|
||
'user_abo' => $abo, 'split_mode' => true,
|
||
'summary' => ['abo' => ['gross' => 100], 'total_with_shipping' => 100],
|
||
'change_locked' => true,
|
||
])->render();
|
||
|
||
expect($html)->not->toContain('add-from-basket')
|
||
->and($html)->not->toContain('remove-from-basket')
|
||
->and($html)->not->toContain('auto-delete-product')
|
||
->and($html)->toContain('readonly');
|
||
});
|
||
});
|