Statistiken/Abo: Kundenfeedback 2026-07-24 komplett umgesetzt + Steuern: DATEV-Fixes 08+09/2025 (Sammel-Commit Branch-WIP)
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>
This commit is contained in:
parent
657d4bd5b7
commit
ac3bd6e8ea
136 changed files with 7610 additions and 2688 deletions
|
|
@ -1,7 +1,11 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Product;
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\UserAbo;
|
||||
use App\Models\UserAboItem;
|
||||
use App\Models\UserLevel;
|
||||
use App\Models\UserSalesVolume;
|
||||
use App\Services\Backoffice\BackofficeDashboardService;
|
||||
use App\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
|
@ -9,6 +13,14 @@ use Tests\TestCase;
|
|||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
if (! Schema::connection('sqlite')->hasColumn('products', 'slug')) {
|
||||
Schema::connection('sqlite')->table('products', function ($table) {
|
||||
$table->string('slug')->nullable();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function bslMakeLevel(string $name, int $pos): UserLevel
|
||||
{
|
||||
return UserLevel::forceCreate([
|
||||
|
|
@ -52,6 +64,33 @@ function bslMakeAbo(int $userId, string $isFor, string $startDate, int $status =
|
|||
]);
|
||||
}
|
||||
|
||||
function bslMakeAboWithPoints(int $userId, string $isFor, string $startDate, int $points): UserAbo
|
||||
{
|
||||
$abo = bslMakeAbo($userId, $isFor, $startDate);
|
||||
|
||||
$product = Product::forceCreate([
|
||||
'name' => 'BSL-'.uniqid('', true),
|
||||
'title' => 'Test',
|
||||
'price' => 10.0,
|
||||
'tax' => 19.0,
|
||||
'active' => true,
|
||||
'pos' => 1,
|
||||
'weight' => 100,
|
||||
'points' => $points,
|
||||
'slug' => 'bsl-'.uniqid('', true),
|
||||
]);
|
||||
|
||||
UserAboItem::create([
|
||||
'user_abo_id' => $abo->id,
|
||||
'product_id' => $product->id,
|
||||
'comp' => 0,
|
||||
'qty' => 1,
|
||||
'status' => 1,
|
||||
]);
|
||||
|
||||
return $abo->fresh();
|
||||
}
|
||||
|
||||
it('rankt neue Kundenabos pro Berater absteigend inklusive Linie', function (): void {
|
||||
$root = bslMakeUser();
|
||||
$anna = bslMakeUser($root->id);
|
||||
|
|
@ -121,3 +160,276 @@ it('liefert eine leere Fuehrungskraefte-Liste, wenn die Stufe Vertriebsleiter ni
|
|||
expect($overview['leader_abos']['total'])->toBe(0)
|
||||
->and($overview['leader_abos']['rows'])->toBe([]);
|
||||
});
|
||||
|
||||
it('ergaenzt die Fuehrungskraefte-Liste um Teamzahlen mit Abschneiden an der naechsten Fuehrungskraft', function (): void {
|
||||
$berater = bslMakeLevel('Berater', 3);
|
||||
$vertriebsleiter = bslMakeLevel('Vertriebsleiter', 6);
|
||||
|
||||
$root = bslMakeUser();
|
||||
$leaderA = bslMakeUser($root->id, $vertriebsleiter->id);
|
||||
$member1 = bslMakeUser($leaderA->id, $berater->id);
|
||||
$leaderB = bslMakeUser($member1->id, $vertriebsleiter->id);
|
||||
$member2 = bslMakeUser($leaderB->id, $berater->id);
|
||||
|
||||
bslMakeAbo($leaderA->id, 'me', '2026-06-01'); // eigenes Abo Leader A
|
||||
bslMakeAbo($member1->id, 'me', '2026-06-01'); // Teamabo von A
|
||||
bslMakeAbo($member1->id, 'ot', '2026-06-01'); // Team-Kundenabo von A
|
||||
bslMakeAbo($leaderB->id, 'ot', '2026-06-01'); // eigenes Kundenabo Leader B
|
||||
bslMakeAbo($member2->id, 'me', '2026-06-01'); // Teamabo von B, zaehlt NICHT bei A
|
||||
|
||||
$overview = app(BackofficeDashboardService::class)->overview($root, 6, 2026);
|
||||
$leaderAbos = $overview['leader_abos'];
|
||||
$rows = collect($leaderAbos['rows'])->keyBy('user_id');
|
||||
|
||||
$a = $rows->get($leaderA->id);
|
||||
$b = $rows->get($leaderB->id);
|
||||
|
||||
expect($a['own_abo_count'])->toBe(1)
|
||||
->and($a['customer_abo_count'])->toBe(0)
|
||||
->and($a['team_abo_count'])->toBe(1)
|
||||
->and($a['team_customer_abo_count'])->toBe(1)
|
||||
->and($a['total_abo_count'])->toBe(3)
|
||||
->and($b['own_abo_count'])->toBe(0)
|
||||
->and($b['customer_abo_count'])->toBe(1)
|
||||
->and($b['team_abo_count'])->toBe(1)
|
||||
->and($b['team_customer_abo_count'])->toBe(0)
|
||||
->and($b['total_abo_count'])->toBe(2);
|
||||
|
||||
expect($leaderAbos['total_own'])->toBe(1)
|
||||
->and($leaderAbos['total_customer'])->toBe(1)
|
||||
->and($leaderAbos['total_team'])->toBe(2)
|
||||
->and($leaderAbos['total_team_customer'])->toBe(1)
|
||||
->and($leaderAbos['total'])->toBe(5);
|
||||
|
||||
// Sortierung nach Gesamtsumme: Leader A (3) vor Leader B (2)
|
||||
expect($leaderAbos['rows'][0]['user_id'])->toBe($leaderA->id);
|
||||
});
|
||||
|
||||
it('liefert Top-10-Kundenabo-Ranglisten nach Anzahl und Umsatz mit Soll und Ist', function (): void {
|
||||
$root = bslMakeUser();
|
||||
$anna = bslMakeUser($root->id);
|
||||
$lena = bslMakeUser($root->id);
|
||||
|
||||
bslMakeAboWithPoints($anna->id, 'ot', '2026-06-01', 20);
|
||||
bslMakeAboWithPoints($anna->id, 'ot', '2026-05-01', 30);
|
||||
bslMakeAboWithPoints($lena->id, 'ot', '2026-04-01', 80);
|
||||
bslMakeAbo($lena->id, 'me', '2026-06-01'); // Eigenabo zaehlt nicht
|
||||
|
||||
// Ist-Umsatz: im Juni abgerechnete Kundenabo-Punkte fuer Lena
|
||||
$order = ShoppingOrder::forceCreate([
|
||||
'shopping_user_id' => 0,
|
||||
'auth_user_id' => $lena->id,
|
||||
'country_id' => 0,
|
||||
'user_shop_id' => 0,
|
||||
'is_abo' => true,
|
||||
'paid' => true,
|
||||
]);
|
||||
UserSalesVolume::forceCreate([
|
||||
'user_id' => $lena->id,
|
||||
'shopping_order_id' => $order->id,
|
||||
'month' => 6,
|
||||
'year' => 2026,
|
||||
'points' => 80,
|
||||
'status' => 2,
|
||||
]);
|
||||
|
||||
$overview = app(BackofficeDashboardService::class)->overview($root, 6, 2026);
|
||||
$top = $overview['top_customer_abos'];
|
||||
|
||||
expect($top['by_count'])->toHaveCount(2)
|
||||
->and($top['by_count'][0]['user_id'])->toBe($anna->id)
|
||||
->and($top['by_count'][0]['abo_count'])->toBe(2)
|
||||
->and($top['by_count'][1]['user_id'])->toBe($lena->id)
|
||||
->and($top['by_count'][1]['abo_count'])->toBe(1);
|
||||
|
||||
expect($top['by_points'][0]['user_id'])->toBe($lena->id)
|
||||
->and($top['by_points'][0]['points_target'])->toBe(80.0)
|
||||
->and($top['by_points'][0]['points_actual'])->toBe(80.0)
|
||||
->and($top['by_points'][1]['user_id'])->toBe($anna->id)
|
||||
->and($top['by_points'][1]['points_target'])->toBe(50.0)
|
||||
->and($top['by_points'][1]['points_actual'])->toBe(0.0);
|
||||
});
|
||||
|
||||
it('ueberlagert Snapshot-Monate mit tagesaktuellen Abo-Bestandszahlen', function (): void {
|
||||
$root = bslMakeUser();
|
||||
$anna = bslMakeUser($root->id);
|
||||
|
||||
$abo = bslMakeAbo($anna->id, 'me', '2026-05-15');
|
||||
|
||||
$service = app(BackofficeDashboardService::class);
|
||||
$service->storeSnapshot($root, 5, 2026);
|
||||
|
||||
// Nach dem Snapshot: Teamabo gekuendigt, neues Kundenabo dazugekommen
|
||||
$abo->update(['status' => 4, 'active' => false]);
|
||||
bslMakeAbo($anna->id, 'ot', '2026-07-01');
|
||||
|
||||
$overview = $service->overview($root, 5, 2026);
|
||||
|
||||
expect($overview['_meta']['source'])->toBe('snapshot')
|
||||
->and($overview['abo_stock_live'])->toBeTrue()
|
||||
->and($overview['lines'][1]['team_partner_abos'])->toBe(0) // Bestand: live
|
||||
->and($overview['lines'][1]['team_customer_abos'])->toBe(1) // Bestand: live
|
||||
->and($overview['lines'][1]['team_partner_abos_new'])->toBe(1) // "+N neu" im Mai: eingefroren
|
||||
->and($overview['totals']['team_partner_abos'])->toBe(0)
|
||||
->and($overview['totals']['team_customer_abos'])->toBe(1);
|
||||
});
|
||||
|
||||
it('ergaenzt beim Snapshot-Overlay Linien, die erst nach dem Snapshot entstanden sind', function (): void {
|
||||
$root = bslMakeUser();
|
||||
$anna = bslMakeUser($root->id);
|
||||
|
||||
bslMakeAbo($anna->id, 'me', '2026-05-15');
|
||||
|
||||
$service = app(BackofficeDashboardService::class);
|
||||
$service->storeSnapshot($root, 5, 2026);
|
||||
|
||||
// Nach dem Snapshot: Team wird tiefer — Linie 2 entsteht und bringt Abo-Bestand mit
|
||||
$ben = bslMakeUser($anna->id);
|
||||
bslMakeAbo($ben->id, 'me', '2026-07-01');
|
||||
bslMakeAbo($ben->id, 'ot', '2026-07-02');
|
||||
|
||||
$overview = $service->overview($root, 5, 2026);
|
||||
|
||||
expect($overview['_meta']['source'])->toBe('snapshot')
|
||||
->and($overview['lines'])->toHaveKey(2)
|
||||
->and($overview['lines'][2]['team_partner_abos'])->toBe(1)
|
||||
->and($overview['lines'][2]['team_customer_abos'])->toBe(1)
|
||||
->and($overview['lines'][2]['team_partner_abos_new'])->toBe(0) // Monatswert: im Snapshot-Monat gab es die Linie nicht
|
||||
->and($overview['totals']['team_partner_abos'])->toBe(2)
|
||||
->and($overview['totals']['team_customer_abos'])->toBe(1);
|
||||
});
|
||||
|
||||
it('nimmt den Statistik-Nutzer selbst in die Kundenabo-Ranglisten auf', function (): void {
|
||||
$root = bslMakeUser();
|
||||
$anna = bslMakeUser($root->id);
|
||||
|
||||
bslMakeAboWithPoints($root->id, 'ot', '2026-06-10', 25);
|
||||
bslMakeAboWithPoints($anna->id, 'ot', '2026-06-05', 15);
|
||||
|
||||
$overview = app(BackofficeDashboardService::class)->overview($root, 6, 2026);
|
||||
|
||||
$newRanking = collect($overview['new_customer_abos'])->keyBy('user_id');
|
||||
expect($newRanking->has($root->id))->toBeTrue()
|
||||
->and($newRanking->get($root->id)['is_self'])->toBeTrue()
|
||||
->and($newRanking->get($root->id)['line'])->toBe(0)
|
||||
->and($newRanking->get($anna->id)['is_self'])->toBeFalse();
|
||||
|
||||
$byCount = collect($overview['top_customer_abos']['by_count'])->keyBy('user_id');
|
||||
expect($byCount->has($root->id))->toBeTrue()
|
||||
->and($byCount->get($root->id)['is_self'])->toBeTrue();
|
||||
|
||||
$byPoints = collect($overview['top_customer_abos']['by_points'])->keyBy('user_id');
|
||||
expect($byPoints->has($root->id))->toBeTrue()
|
||||
->and($byPoints->get($root->id)['points_target'])->toBe(25.0);
|
||||
});
|
||||
|
||||
it('sortiert die Top-10-Umsatzliste nach abgerechneten Ist-Punkten und liefert die Abo-Anzahl mit', function (): void {
|
||||
$root = bslMakeUser();
|
||||
$anna = bslMakeUser($root->id); // hoher Bestand, nichts abgerechnet
|
||||
$lena = bslMakeUser($root->id); // kleiner Bestand, viel abgerechnet
|
||||
|
||||
bslMakeAboWithPoints($anna->id, 'ot', '2026-06-01', 100);
|
||||
bslMakeAboWithPoints($lena->id, 'ot', '2026-06-01', 30);
|
||||
bslMakeAboWithPoints($lena->id, 'ot', '2026-06-02', 30);
|
||||
|
||||
$order = ShoppingOrder::forceCreate([
|
||||
'shopping_user_id' => 0,
|
||||
'auth_user_id' => $lena->id,
|
||||
'country_id' => 0,
|
||||
'user_shop_id' => 0,
|
||||
'is_abo' => true,
|
||||
'paid' => true,
|
||||
]);
|
||||
UserSalesVolume::forceCreate([
|
||||
'user_id' => $lena->id,
|
||||
'shopping_order_id' => $order->id,
|
||||
'month' => 6,
|
||||
'year' => 2026,
|
||||
'points' => 90,
|
||||
'status' => 2,
|
||||
]);
|
||||
|
||||
$byPoints = app(BackofficeDashboardService::class)->overview($root, 6, 2026)['top_customer_abos']['by_points'];
|
||||
|
||||
expect($byPoints[0]['user_id'])->toBe($lena->id)
|
||||
->and($byPoints[0]['points_actual'])->toBe(90.0)
|
||||
->and($byPoints[0]['points_target'])->toBe(60.0)
|
||||
->and($byPoints[0]['abo_count'])->toBe(2)
|
||||
->and($byPoints[1]['user_id'])->toBe($anna->id)
|
||||
->and($byPoints[1]['points_target'])->toBe(100.0)
|
||||
->and($byPoints[1]['abo_count'])->toBe(1);
|
||||
});
|
||||
|
||||
it('liefert je Fuehrungskraft zusaetzlich die Teamzahlen der kompletten Downline', function (): void {
|
||||
$berater = bslMakeLevel('Berater', 3);
|
||||
$vertriebsleiter = bslMakeLevel('Vertriebsleiter', 6);
|
||||
|
||||
$root = bslMakeUser();
|
||||
$leaderA = bslMakeUser($root->id, $vertriebsleiter->id);
|
||||
$member1 = bslMakeUser($leaderA->id, $berater->id);
|
||||
$leaderB = bslMakeUser($member1->id, $vertriebsleiter->id);
|
||||
$member2 = bslMakeUser($leaderB->id, $berater->id);
|
||||
|
||||
bslMakeAbo($leaderA->id, 'me', '2026-06-01');
|
||||
bslMakeAbo($member1->id, 'me', '2026-06-01');
|
||||
bslMakeAbo($member1->id, 'ot', '2026-06-01');
|
||||
bslMakeAbo($leaderB->id, 'ot', '2026-06-01');
|
||||
bslMakeAbo($member2->id, 'me', '2026-06-01'); // zaehlt bei A nur in der Gesamt-Sicht
|
||||
|
||||
$rows = collect(app(BackofficeDashboardService::class)->overview($root, 6, 2026)['leader_abos']['rows'])->keyBy('user_id');
|
||||
$a = $rows->get($leaderA->id);
|
||||
$b = $rows->get($leaderB->id);
|
||||
|
||||
// Direkt (an Leader B abgeschnitten) bleibt unveraendert additiv
|
||||
expect($a['team_abo_count'])->toBe(1)
|
||||
->and($a['team_customer_abo_count'])->toBe(1)
|
||||
// Gesamt: komplette Downline inkl. Leader B und dessen Team
|
||||
->and($a['team_abo_count_total'])->toBe(2)
|
||||
->and($a['team_customer_abo_count_total'])->toBe(2)
|
||||
->and($a['total_abo_count_full'])->toBe(5)
|
||||
->and($b['team_abo_count_total'])->toBe(1)
|
||||
->and($b['total_abo_count_full'])->toBe(2);
|
||||
});
|
||||
|
||||
it('summiert Punkte ohne Payline-Wirkung (status_points 2) als eigene Kennzahl', function (): void {
|
||||
$root = bslMakeUser();
|
||||
$anna = bslMakeUser($root->id);
|
||||
|
||||
UserSalesVolume::forceCreate([
|
||||
'user_id' => $anna->id,
|
||||
'month' => 6,
|
||||
'year' => 2026,
|
||||
'points' => 200,
|
||||
'status' => 5,
|
||||
'status_points' => 2, // Neupartner-Einschreibung: nur KP, nicht Payline
|
||||
]);
|
||||
UserSalesVolume::forceCreate([
|
||||
'user_id' => $anna->id,
|
||||
'month' => 6,
|
||||
'year' => 2026,
|
||||
'points' => 150,
|
||||
'status' => 1,
|
||||
'status_points' => 1, // normale Bestellung: payline-wirksam
|
||||
]);
|
||||
|
||||
$overview = app(BackofficeDashboardService::class)->overview($root, 6, 2026);
|
||||
|
||||
expect($overview['non_payline_points'])->toBe(200.0)
|
||||
->and($overview['totals']['total_points'])->toBe(350.0);
|
||||
});
|
||||
|
||||
it('ignoriert Abo-Punkte geloeschter Produkte im Soll der Top-10-Umsatzliste', function (): void {
|
||||
$root = bslMakeUser();
|
||||
$anna = bslMakeUser($root->id);
|
||||
|
||||
$aboLive = bslMakeAboWithPoints($anna->id, 'ot', '2026-06-01', 40);
|
||||
$aboDeleted = bslMakeAboWithPoints($anna->id, 'ot', '2026-06-02', 60);
|
||||
Product::whereKey(UserAboItem::where('user_abo_id', $aboDeleted->id)->value('product_id'))->first()->delete();
|
||||
|
||||
$overview = app(BackofficeDashboardService::class)->overview($root, 7, 2026);
|
||||
$byPoints = $overview['top_customer_abos']['by_points'];
|
||||
|
||||
expect($byPoints)->toHaveCount(1)
|
||||
->and($byPoints[0]['user_id'])->toBe($anna->id)
|
||||
->and($byPoints[0]['points_target'])->toBe(40.0);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue