Statistik-Änderungen 2026-08-19 (dev/2026-07-22/entwicklungsplan-2026-08-19.md, alle 8 Pakete gemäß beantworteten F-Fragen): - N1 Monatsstatistik: Team-Cluster (Firstlines klickbar -> neue Seite user_team_firstlines, Teampartner gesamt/Payline [nur aktive], Abos Payline mit +N) + Level-Kachel (Quali-Stufe, fehlende Payline-/persönliche Punkte, Karriere-Level); LiveCommissionService-Snapshot v3 reicht Level-/Quali-Daten durch - N2 Abo-Kacheln auf Home monatsbezogen (aboStockCount-Rückrechnung über cancel/deleted/Status-Historie; VIP-Statistik bleibt tagesaktuell) - N3 VIP-Statistik 'Recruiter des Monats' (Neupartner-Definition wie KPI, rekursives Ketten-Volumen als Gesamtpunkte inkl. Starterpaket) + Drilldown/CSV-Spalte 'Punkte gesamt' - N4 Prognose-Block 'Level & Provision laut Prognose (Stand 25.)' mit unverbindlich-Kennzeichnung (ebenengenaue offene Abo-Punkte + TP-Laufstände) - N5 Checkout-Herkunft: Dropdown raus, Pflicht-Freitext (lokalisierte Fehlermeldung) - N6 Berater-Profilfoto (user_accounts.profile_image, Einwilligung als Sprachkey-Platzhalter, DSGVO-Löschung) + teaminterne Top-3-Karte (TopResultsService, Scope-Schalter) - N7 USt-Optionen: 'nicht umsatzsteuerpflichtig' zuerst + '§ 19 UStG'-Textfix - N8 Jahresgebühr-Nachfassliste je Berater (AnnualFeeDueService, überfällig/1-3 Monate, Sidenav) Kontroll-Review (8 Perspektiven) angewendet: next-Level-Fallback im Steady-State (resolveNextLevel — sonst 'Höchstes Level erreicht' für Normalfälle), _calculated-Punktebasis bei mehr Paylines, keine Wiederbelebung deaktivierter Abos in der Monatsrückrechnung, Payline-Abo-Kachel monatskonsistent, Top-3-Nachrücker; Performance: BackofficeDashboardService als Singleton mit lineBuckets-Memo (+flushLineBucketsMemo), level_forecast 6h-Cache; Reuse: User-Scopes firstlinesOf/newPartnerIn, SalesVolumePoints::totalPointsByUser, Slim::storeCroppedImage (3 Upload-Kopien vereinigt), activeAbo-Scope statt Inline-Prädikate, Form::open-Konvention, Return-Types Repair-Härtung (Kundenentscheide): VP-Nachtrepair dedupliziert über shopping_order_id (Live-Pfad schreibt sie jetzt mit), zählende Alt-Zeilen-Erkennung, Vormonats-Scan für Monatswechsel, Firmenaccount-Buchungen (config mivita.company_user_id) werden automatisch auf den Berater umgebucht (in place bzw. per Gegenbuchung bei geschlossenem Monat; Echtfall Anna Schall Order 58797 -> Christina Schuder), Berater-zu-Berater nur Warnung; Incentive-Repair: m_sponsor=NULL löscht nicht mehr, FK-Repair vor Stale-Bereinigung, gebatchte Lookups, zentrales Incentive::qualificationWindow(); .env aus dem Git-Tracking entfernt (Pflege per SFTP) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
329 lines
13 KiB
PHP
329 lines
13 KiB
PHP
<?php
|
|
|
|
use App\Models\Product;
|
|
use App\Models\ShoppingOrder;
|
|
use App\Models\UserAbo;
|
|
use App\Models\UserAboItem;
|
|
use App\Models\UserBusiness;
|
|
use App\Models\UserSalesVolume;
|
|
use App\Services\Abo\PaylineForecastService;
|
|
use App\User;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\URL;
|
|
use Tests\TestCase;
|
|
|
|
uses(TestCase::class, RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
Carbon::setTestNow(Carbon::parse('2026-07-02'));
|
|
Cache::flush();
|
|
|
|
if (! Schema::connection('sqlite')->hasColumn('products', 'slug')) {
|
|
Schema::connection('sqlite')->table('products', function ($table) {
|
|
$table->string('slug')->nullable();
|
|
});
|
|
}
|
|
});
|
|
|
|
afterEach(function () {
|
|
Carbon::setTestNow();
|
|
});
|
|
|
|
function pflMakeUser(?int $sponsorId = null): User
|
|
{
|
|
return User::forceCreate([
|
|
'email' => 'pfl-'.uniqid('', true).'@example.com',
|
|
'password' => bcrypt('secret'),
|
|
'lang' => 'de',
|
|
'm_level' => 1,
|
|
'm_sponsor' => $sponsorId,
|
|
'payment_account' => now()->addYear(),
|
|
]);
|
|
}
|
|
|
|
function pflMakeAbo(int $userId, string $isFor, string $nextDate, int $points, int $status = 2): UserAbo
|
|
{
|
|
$abo = UserAbo::create([
|
|
'user_id' => $userId,
|
|
'member_id' => $userId,
|
|
'shopping_user_id' => 0,
|
|
'email' => 'pfl-abo-'.uniqid('', true).'@example.com',
|
|
'is_for' => $isFor,
|
|
'payone_userid' => 0,
|
|
'clearingtype' => 'cc',
|
|
'amount' => 5000,
|
|
'active' => $status === 2,
|
|
'status' => $status,
|
|
'abo_interval' => (int) Carbon::parse($nextDate)->day,
|
|
'start_date' => '2026-01-05',
|
|
'last_date' => '2026-06-05',
|
|
'next_date' => $nextDate,
|
|
]);
|
|
|
|
$product = Product::forceCreate([
|
|
'name' => 'PFL-'.uniqid('', true),
|
|
'title' => 'Test',
|
|
'price' => 10.0,
|
|
'tax' => 19.0,
|
|
'active' => true,
|
|
'pos' => 1,
|
|
'weight' => 100,
|
|
'points' => $points,
|
|
'slug' => 'pfl-'.uniqid('', true),
|
|
]);
|
|
|
|
UserAboItem::create([
|
|
'user_abo_id' => $abo->id,
|
|
'product_id' => $product->id,
|
|
'comp' => 0,
|
|
'qty' => 1,
|
|
'status' => 1,
|
|
]);
|
|
|
|
return $abo->fresh();
|
|
}
|
|
|
|
function pflSeedContext(User $user, array $memberIds, int $paylines = 2): void
|
|
{
|
|
Cache::put(
|
|
"payline_context_{$user->id}_".now()->month.'_'.now()->year,
|
|
['paylines' => $paylines, 'member_ids' => $memberIds],
|
|
now()->addHour()
|
|
);
|
|
}
|
|
|
|
it('berechnet die Prognose kumuliert je Ausfuehrungstermin inklusive eigener Abos', function (): void {
|
|
$root = pflMakeUser();
|
|
$anna = pflMakeUser($root->id);
|
|
$fremd = pflMakeUser();
|
|
|
|
pflSeedContext($root, [$anna->id]);
|
|
|
|
pflMakeAbo($anna->id, 'ot', '2026-07-05', 20); // Payline-Kundenabo zum 5.
|
|
pflMakeAbo($root->id, 'me', '2026-07-10', 30); // eigenes Abo (F12: zaehlt mit)
|
|
pflMakeAbo($anna->id, 'me', '2026-07-25', 10); // Payline-Beraterabo zum 25.
|
|
pflMakeAbo($fremd->id, 'me', '2026-07-10', 99); // ausserhalb der Payline
|
|
pflMakeAbo($anna->id, 'ot', '2026-08-05', 50); // naechster Monat, zaehlt nicht
|
|
pflMakeAbo($anna->id, 'ot', '2026-07-20', 40, 3); // angehalten, zaehlt nicht
|
|
|
|
$forecast = app(PaylineForecastService::class)->forecastDays($root, 100.0);
|
|
|
|
expect($forecast['base'])->toBe(100.0)
|
|
->and($forecast['days'])->toHaveCount(4)
|
|
->and($forecast['days'][0])->toMatchArray(['day' => 5, 'upcoming' => 20.0, 'total' => 120.0, 'is_past' => false])
|
|
->and($forecast['days'][1])->toMatchArray(['day' => 10, 'upcoming' => 50.0, 'total' => 150.0])
|
|
->and($forecast['days'][2])->toMatchArray(['day' => 20, 'upcoming' => 50.0, 'total' => 150.0])
|
|
->and($forecast['days'][3])->toMatchArray(['day' => 25, 'upcoming' => 60.0, 'total' => 160.0]);
|
|
});
|
|
|
|
it('markiert vergangene Termine und zaehlt nur noch offene Ausfuehrungen', function (): void {
|
|
Carbon::setTestNow(Carbon::parse('2026-07-22'));
|
|
|
|
$root = pflMakeUser();
|
|
$anna = pflMakeUser($root->id);
|
|
pflSeedContext($root, [$anna->id]);
|
|
|
|
pflMakeAbo($anna->id, 'ot', '2026-07-25', 20); // noch offen
|
|
pflMakeAbo($anna->id, 'me', '2026-08-05', 30); // bereits fortgeschrieben (naechster Monat)
|
|
|
|
$forecast = app(PaylineForecastService::class)->forecastDays($root, 100.0);
|
|
|
|
expect($forecast['days'][0])->toMatchArray(['day' => 5, 'upcoming' => 0.0, 'is_past' => true])
|
|
->and($forecast['days'][2])->toMatchArray(['day' => 20, 'upcoming' => 0.0, 'is_past' => true])
|
|
->and($forecast['days'][3])->toMatchArray(['day' => 25, 'upcoming' => 20.0, 'total' => 120.0, 'is_past' => false]);
|
|
});
|
|
|
|
it('liefert Soll und Ist des Payline-Abo-Umsatzes', function (): void {
|
|
$root = pflMakeUser();
|
|
$anna = pflMakeUser($root->id);
|
|
$fremd = pflMakeUser();
|
|
|
|
pflSeedContext($root, [$anna->id]);
|
|
|
|
pflMakeAbo($anna->id, 'me', '2026-07-10', 20); // Soll 20
|
|
pflMakeAbo($anna->id, 'ot', '2026-07-25', 30); // Soll 30
|
|
pflMakeAbo($anna->id, 'ot', '2026-07-25', 40, 4); // gekuendigt, kein Soll
|
|
pflMakeAbo($fremd->id, 'me', '2026-07-10', 99); // ausserhalb der Payline
|
|
|
|
$aboOrder = ShoppingOrder::forceCreate([
|
|
'shopping_user_id' => 0,
|
|
'auth_user_id' => $anna->id,
|
|
'country_id' => 0,
|
|
'user_shop_id' => 0,
|
|
'is_abo' => true,
|
|
'paid' => true,
|
|
]);
|
|
$shopOrder = ShoppingOrder::forceCreate([
|
|
'shopping_user_id' => 0,
|
|
'auth_user_id' => $anna->id,
|
|
'country_id' => 0,
|
|
'user_shop_id' => 0,
|
|
'is_abo' => false,
|
|
'paid' => true,
|
|
]);
|
|
|
|
UserSalesVolume::forceCreate(['user_id' => $anna->id, 'shopping_order_id' => $aboOrder->id, 'month' => 7, 'year' => 2026, 'points' => 25, 'status' => 2]);
|
|
UserSalesVolume::forceCreate(['user_id' => $anna->id, 'shopping_order_id' => $shopOrder->id, 'month' => 7, 'year' => 2026, 'points' => 50, 'status' => 2]); // kein Abo
|
|
UserSalesVolume::forceCreate(['user_id' => $anna->id, 'shopping_order_id' => $aboOrder->id, 'month' => 6, 'year' => 2026, 'points' => 60, 'status' => 2]); // anderer Monat
|
|
|
|
$turnover = app(PaylineForecastService::class)->aboTurnover($root, 7, 2026);
|
|
|
|
expect($turnover['paylines'])->toBe(2)
|
|
->and($turnover['members'])->toBe(1)
|
|
->and($turnover['target'])->toBe(50.0)
|
|
->and($turnover['actual'])->toBe(25.0)
|
|
->and($turnover['team_abo_count'])->toBe(1) // nur Annas Eigenabo, nicht der Fremde
|
|
->and($turnover['team_customer_abo_count'])->toBe(1); // gekuendigtes Kundenabo zaehlt nicht
|
|
});
|
|
|
|
it('liefert leere Kennzahlen ohne Payline, Prognose enthaelt aber eigene Abos', function (): void {
|
|
$root = pflMakeUser();
|
|
pflSeedContext($root, [], 0);
|
|
|
|
pflMakeAbo($root->id, 'me', '2026-07-10', 30);
|
|
|
|
$turnover = app(PaylineForecastService::class)->aboTurnover($root, 7, 2026);
|
|
$forecast = app(PaylineForecastService::class)->forecastDays($root, 10.0);
|
|
|
|
expect($turnover['target'])->toBe(0.0)
|
|
->and($turnover['actual'])->toBe(0.0)
|
|
->and($forecast['days'][1])->toMatchArray(['day' => 10, 'upcoming' => 30.0, 'total' => 40.0]);
|
|
});
|
|
|
|
it('ermittelt den Payline-Kontext aus dem UserBusiness-Level und den Linien', function (): void {
|
|
$root = pflMakeUser();
|
|
$anna = pflMakeUser($root->id); // Linie 1
|
|
$ben = pflMakeUser($anna->id); // Linie 2
|
|
|
|
UserBusiness::forceCreate([
|
|
'user_id' => $root->id,
|
|
'month' => 7,
|
|
'year' => 2026,
|
|
'version' => 1,
|
|
'qual_user_level' => ['paylines' => 1],
|
|
]);
|
|
|
|
$context = app(PaylineForecastService::class)->paylineContext($root, 7, 2026);
|
|
|
|
expect($context['paylines'])->toBe(1)
|
|
->and($context['member_ids'])->toBe([$anna->id])
|
|
->and($context['member_ids'])->not->toContain($ben->id);
|
|
});
|
|
|
|
it('rendert die Payline-Prognose auf der Monatsstatistik-Card', function (): void {
|
|
URL::resolveMissingNamedRoutesUsing(fn (string $name, $parameters = []) => '/'.$name);
|
|
|
|
$html = view('dashboard._statistics', [
|
|
'monthlyStats' => [
|
|
'month' => 7,
|
|
'year' => 2026,
|
|
'customer_points' => 100.0,
|
|
'team_payline_points' => 400.0,
|
|
'payline_is_live' => true,
|
|
'payline_forecast' => [
|
|
'base' => 500.0,
|
|
'days' => [
|
|
['day' => 5, 'date' => '05.07.2026', 'upcoming' => 0.0, 'total' => 500.0, 'is_past' => true],
|
|
['day' => 10, 'date' => '10.07.2026', 'upcoming' => 30.0, 'total' => 530.0, 'is_past' => false],
|
|
['day' => 20, 'date' => '20.07.2026', 'upcoming' => 30.0, 'total' => 530.0, 'is_past' => false],
|
|
['day' => 25, 'date' => '25.07.2026', 'upcoming' => 60.0, 'total' => 560.0, 'is_past' => false],
|
|
],
|
|
],
|
|
'direct_new_partners' => 0,
|
|
'team_total_abos' => 0,
|
|
'team_partner_abos' => 0,
|
|
'team_partner_abos_new' => 0,
|
|
'team_customer_abos' => 0,
|
|
'team_customer_abos_new' => 0,
|
|
'shop_1000' => 0,
|
|
'new_partners' => 0,
|
|
'own_abos' => 0,
|
|
'own_abos_new' => 0,
|
|
'own_customer_abos' => 0,
|
|
'own_customer_abos_new' => 0,
|
|
],
|
|
])->render();
|
|
|
|
expect($html)
|
|
->toContain(e(__('home.payline_forecast_title')))
|
|
->toContain(__('home.forecast_day', ['date' => '25.07.2026']))
|
|
->toContain('560')
|
|
->toContain(__('home.forecast_done'));
|
|
});
|
|
|
|
it('zaehlt neue Abos der Payline-Mitglieder im Monat', function (): void {
|
|
$root = pflMakeUser();
|
|
$anna = pflMakeUser($root->id);
|
|
$fremd = pflMakeUser();
|
|
|
|
pflSeedContext($root, [$anna->id]);
|
|
|
|
pflMakeAbo($anna->id, 'me', '2026-07-10', 20)->update(['start_date' => '2026-07-03']); // neu im Juli
|
|
pflMakeAbo($anna->id, 'ot', '2026-07-25', 30)->update(['start_date' => '2026-07-05']); // neu im Juli
|
|
pflMakeAbo($anna->id, 'ot', '2026-07-25', 40)->update(['start_date' => '2026-06-10']); // Bestand, nicht neu
|
|
pflMakeAbo($fremd->id, 'me', '2026-07-10', 99)->update(['start_date' => '2026-07-04']); // ausserhalb Payline
|
|
|
|
expect(app(PaylineForecastService::class)->newPaylineAboCount($root, 7, 2026))->toBe(2);
|
|
});
|
|
|
|
it('prognostiziert Level und Provision zum letzten Liefertag', function (): void {
|
|
// Test-Schema hinkt der Produktions-Tabelle hinterher (bekannte Stolperfalle)
|
|
foreach (['sales_volume_points_KP_sum' => 'float', 'payline_points_qual_kp' => 'float', 'qual_user_level_next' => 'text'] as $column => $type) {
|
|
if (! Schema::connection('sqlite')->hasColumn('user_businesses', $column)) {
|
|
Schema::connection('sqlite')->table('user_businesses', function ($table) use ($column, $type) {
|
|
$type === 'float' ? $table->float($column)->nullable() : $table->text($column)->nullable();
|
|
});
|
|
}
|
|
}
|
|
|
|
$root = pflMakeUser();
|
|
$anna = pflMakeUser($root->id); // Ebene 1
|
|
|
|
pflSeedContext($root, [$anna->id]);
|
|
|
|
$levelBase = [
|
|
'qual_kp' => 0, 'growth_bonus' => 0,
|
|
'pr_line_1' => 7, 'pr_line_2' => 5, 'pr_line_3' => 0, 'pr_line_4' => 0,
|
|
'pr_line_5' => 0, 'pr_line_6' => 0, 'pr_line_7' => 0, 'pr_line_8' => 0,
|
|
];
|
|
UserBusiness::forceCreate([
|
|
'user_id' => $root->id,
|
|
'month' => 7,
|
|
'year' => 2026,
|
|
'version' => 1,
|
|
'payline_points' => 800.0,
|
|
'payline_points_qual_kp' => 800.0,
|
|
'sales_volume_points_KP_sum' => 500.0,
|
|
'commission_pp_total' => 0,
|
|
'commission_shop_sales' => 0,
|
|
'commission_growth_total' => 0,
|
|
'qual_user_level' => array_merge($levelBase, ['name' => 'Gold', 'paylines' => 2, 'qual_pp' => 500]),
|
|
'qual_user_level_next' => array_merge($levelBase, ['name' => 'Diamant', 'paylines' => 2, 'qual_pp' => 2000, 'qual_kp' => 600]),
|
|
]);
|
|
|
|
// Heutiger TP-Laufstand von Anna (Ebene 1): 800 Punkte
|
|
UserSalesVolume::forceCreate([
|
|
'user_id' => $anna->id, 'month' => 7, 'year' => 2026,
|
|
'points' => 800, 'status' => 1, 'month_TP_points' => 800, 'month_shop_points' => 0,
|
|
]);
|
|
|
|
// Offene Ausfuehrungen bis zum 25.: Anna +100 (Ebene 1), eigenes Kundenabo +50 (persoenlich)
|
|
pflMakeAbo($anna->id, 'ot', '2026-07-20', 100);
|
|
pflMakeAbo($root->id, 'ot', '2026-07-10', 50);
|
|
|
|
$forecast = app(PaylineForecastService::class)->levelAndCommissionForecast($root, 7, 2026);
|
|
|
|
// Persoenlich: 500 + 50 = 550 -> zu Diamant (600) fehlen 50
|
|
// Payline: Ebene 1 = 800 + 100 = 900, kein KP-Ueberschuss -> zu 2000 fehlen 1100
|
|
// Provision (Level Gold, da nicht erreicht): 900 x 7 % = 63
|
|
expect($forecast)->not->toBeNull()
|
|
->and($forecast['next_name'])->toBe('Diamant')
|
|
->and($forecast['reached'])->toBeFalse()
|
|
->and($forecast['missing_pp'])->toBe(1100.0)
|
|
->and($forecast['missing_personal'])->toBe(50.0)
|
|
->and($forecast['personal_forecast'])->toBe(550.0)
|
|
->and($forecast['estimated_level_name'])->toBe('Gold')
|
|
->and($forecast['estimated_commission'])->toBe(63.0);
|
|
});
|