mivita/tests/Feature/PaylineForecastServiceTest.php
Kevin 6cd00a60ec Payline (A07): Home-Kacheln und Prognose zaehlen die Mitglieder des Provisionsbaums
paylineContext() nahm die gesamte Downline, lineByUserId() und die
Team-Statistik den Provisionsbaum. "Teampartner/Abos in der Payline" und
die Payline-Prognose zaehlten damit auch Berater ohne Mitgliedsebene oder
Abrechnungskonto samt ihrem Unterbaum, deren Abo-Punkte nie in die
Provision eingehen. An der Live-Kopie betrifft das 102 von 162 Beratern
mit Payline. Nach dem Deploy cache:clear (payline_context_*, 6 h).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 15:44:24 +00:00

732 lines
31 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, ?string $activeDate = '2026-01-10'): User
{
// active_date ist gesetzt wie in der Produktion: der Provisionsbaum nimmt nur
// Teampartner auf, die bis zum Monatsende aktiv geworden sind
// (BusinessUserItemOptimized::readParentsBusinessUsers()).
return User::forceCreate([
'email' => 'pfl-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'm_level' => 1,
'm_sponsor' => $sponsorId,
'payment_account' => now()->addYear(),
'active_date' => $activeDate,
'partner_paid_at' => $activeDate,
]);
}
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 pflBookAboPoints(int $userId, int $orderId, float $points, string $createdAt, int $month = 7, int $year = 2026): UserSalesVolume
{
return UserSalesVolume::forceCreate([
'user_id' => $userId,
'shopping_order_id' => $orderId,
'month' => $month,
'year' => $year,
'points' => $points,
'status' => 2,
'created_at' => $createdAt,
]);
}
function pflSeedContext(User $user, array $memberIds, int $paylines = 2, ?int $month = null, ?int $year = null): void
{
$month ??= (int) now()->month;
$year ??= (int) now()->year;
Cache::put(
"payline_context_{$user->id}_{$month}_{$year}",
['paylines' => $paylines, 'member_ids' => $memberIds, 'is_career_fallback' => false],
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(5)
->and($forecast['days'][0])->toMatchArray(['day' => 5, 'upcoming' => 20.0, 'total' => 120.0, 'is_past' => false, 'is_month_end' => 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])
->and($forecast['days'][4])->toMatchArray(['day' => 31, 'upcoming' => 60.0, 'total' => 160.0, 'is_month_end' => true]);
});
it('nimmt Abos nach dem 25. in die Monatsende-Kachel auf (A1: freie Liefertage)', function (): void {
Carbon::setTestNow(Carbon::parse('2026-07-02'));
$root = pflMakeUser();
$anna = pflMakeUser($root->id);
pflSeedContext($root, [$anna->id]);
pflMakeAbo($anna->id, 'ot', '2026-07-25', 20);
pflMakeAbo($anna->id, 'ot', '2026-07-28', 30);
pflMakeAbo($anna->id, 'ot', '2026-07-31', 40);
$forecast = app(PaylineForecastService::class)->forecastDays($root, 0.0);
expect($forecast['days'][3])->toMatchArray(['day' => 25, 'upcoming' => 20.0])
->and($forecast['days'][4])->toMatchArray(['day' => 31, 'date' => '31.07.2026', 'upcoming' => 90.0, 'total' => 90.0, 'is_month_end' => true]);
});
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('zaehlt eine heute faellige, noch nicht ausgefuehrte Lieferung mit (A13)', function (): void {
// 10.07., 00:01 Uhr: der Ausfuehrungsjob laeuft erst um 04:00. Bis dahin ist
// das Abo weder im Ist gebucht noch — bisher — in der Prognose enthalten.
Carbon::setTestNow(Carbon::parse('2026-07-10 00:01:00'));
$root = pflMakeUser();
$anna = pflMakeUser($root->id);
pflSeedContext($root, [$anna->id]);
pflMakeAbo($anna->id, 'ot', '2026-07-10', 50);
$forecast = app(PaylineForecastService::class)->forecastDays($root, 0.0);
expect($forecast['days'][1])->toMatchArray(['day' => 10, 'upcoming' => 50.0])
->and($forecast['days'][3])->toMatchArray(['day' => 25, 'upcoming' => 50.0]);
});
it('zaehlt eine heute bereits ausgefuehrte Lieferung nicht doppelt (A13)', function (): void {
Carbon::setTestNow(Carbon::parse('2026-07-10 06:00:00'));
$root = pflMakeUser();
$anna = pflMakeUser($root->id);
pflSeedContext($root, [$anna->id]);
// Der Job hat die Ausfuehrung angelegt; die Punkte stecken damit im Ist.
$abo = pflMakeAbo($anna->id, 'ot', '2026-07-10', 50);
$abo->user_abo_orders()->create(['shopping_order_id' => 0, 'status' => 1, 'paid' => true]);
$forecast = app(PaylineForecastService::class)->forecastDays($root, 0.0);
expect($forecast['days'][1])->toMatchArray(['day' => 10, 'upcoming' => 0.0])
->and($forecast['days'][3])->toMatchArray(['day' => 25, 'upcoming' => 0.0]);
});
it('zaehlt eine gestern faellige Lieferung nicht mehr mit (A13)', function (): void {
// Nur der heutige Liefertag ist offen; ein liegengebliebener Vortag gehoert
// in die Nachbearbeitung, nicht in die Prognose.
Carbon::setTestNow(Carbon::parse('2026-07-11 09:00:00'));
$root = pflMakeUser();
$anna = pflMakeUser($root->id);
pflSeedContext($root, [$anna->id]);
pflMakeAbo($anna->id, 'ot', '2026-07-10', 50);
$forecast = app(PaylineForecastService::class)->forecastDays($root, 0.0);
expect($forecast['days'][3])->toMatchArray(['day' => 25, 'upcoming' => 0.0]);
});
it('zaehlt den heutigen Liefertag auch in der persoenlichen Vorschau (A13)', function (): void {
Carbon::setTestNow(Carbon::parse('2026-07-10 00:01:00'));
$berater = pflMakeUser();
pflMakeAbo($berater->id, 'ot', '2026-07-10', 50);
$forecast = app(PaylineForecastService::class)->personalCustomerAboForecast($berater);
expect($forecast['days'][1])->toMatchArray(['day' => 10, 'upcoming' => 50.0]);
});
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('nimmt die Payline-Mitglieder aus dem Provisionsbaum wie die Team-Statistik (A07)', function (): void {
$root = pflMakeUser();
$anna = pflMakeUser($root->id); // Linie 1, im Provisionsbaum
$carl = pflMakeUser($root->id); // Linie 1, ohne Abrechnungskonto
$carl->forceFill(['payment_account' => null])->save();
$dora = pflMakeUser($carl->id); // Linie 2 unter Carl: gekappt
$emil = pflMakeUser($anna->id); // Linie 2 unter Anna: zaehlt
UserBusiness::forceCreate([
'user_id' => $root->id,
'month' => 7,
'year' => 2026,
'version' => 1,
'qual_user_level' => ['paylines' => 2],
]);
pflMakeAbo($dora->id, 'me', '2026-07-20', 50);
pflMakeAbo($emil->id, 'me', '2026-07-20', 30);
$service = app(PaylineForecastService::class);
$context = $service->paylineContext($root, 7, 2026);
$turnover = $service->aboTurnover($root, 7, 2026);
expect($context['member_ids'])->toEqualCanonicalizing([$anna->id, $emil->id])
->and($context['member_ids'])->not->toContain($carl->id)
->and($context['member_ids'])->not->toContain($dora->id)
->and($turnover['team_abo_count'])->toBe(1)
->and($turnover['target'])->toBe(30.0);
});
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);
});
it('haelt das "+N neu"-Badge der Payline-Kachel fuer abgeschlossene Monate stabil', function (): void {
// Regression: das Badge zaehlte ueber activeAbo() (heutiger Stand), der
// Bestand daneben ueber die Monatsend-Rueckrechnung. Ein im Juli gestartetes
// und im August beendetes Abo fiel damit aus der Juli-Zahl heraus.
Carbon::setTestNow(Carbon::parse('2026-08-15'));
$root = pflMakeUser();
$anna = pflMakeUser($root->id);
pflSeedContext($root, [$anna->id], month: 7, year: 2026);
pflMakeAbo($anna->id, 'ot', '2026-08-25', 30)
->update(['start_date' => '2026-07-05', 'active' => false, 'cancel_date' => '2026-08-05']);
pflMakeAbo($anna->id, 'me', '2026-08-10', 20)->update(['start_date' => '2026-07-03']);
// Im August gestartet: gehoert nicht in die Juli-Zahl.
pflMakeAbo($anna->id, 'ot', '2026-08-20', 15)->update(['start_date' => '2026-08-02']);
expect(app(PaylineForecastService::class)->newPaylineAboCount($root, 7, 2026))->toBe(2);
});
it('rechnet die Prognose ueber denselben Baum wie der Provisionslauf', function (): void {
// lineBuckets() nimmt jeden Downline-User, der TreeCalcBot filtert und kappt
// den Unterbaum darunter. Ohne denselben Filter zaehlte die Prognose Punkte
// mit, die in den payline_points der Level-Kachel gar nicht stecken —
// zwei verschiedene "fehlen noch X Punkte" auf einer Seite.
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();
// Ebene 1 ohne Abrechnungskonto: steht nicht im Provisionsbaum ...
$inaktiv = pflMakeUser($root->id);
$inaktiv->forceFill(['payment_account' => null])->save();
// ... und damit auch nicht sein Teampartner auf Ebene 2.
$unterInaktiv = pflMakeUser($inaktiv->id);
// Ebene 1, erst nach dem Monatsende aktiv geworden: ebenfalls draussen.
pflMakeUser($root->id, '2026-08-15');
$anna = pflMakeUser($root->id); // zaehlt
$levelBase = [
'qual_kp' => 0, 'growth_bonus' => 0,
'pr_line_1' => 10, 'pr_line_2' => 0, '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' => 100.0,
'payline_points_qual_kp' => 100.0,
'sales_volume_points_KP_sum' => 0.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' => 0]),
'qual_user_level_next' => array_merge($levelBase, ['name' => 'Diamant', 'paylines' => 2, 'qual_pp' => 1000, 'qual_kp' => 0]),
]);
foreach ([$inaktiv->id => 500, $unterInaktiv->id => 300, $anna->id => 100] as $userId => $points) {
UserSalesVolume::forceCreate([
'user_id' => $userId, 'month' => 7, 'year' => 2026,
'points' => $points, 'status' => 1, 'month_TP_points' => $points, 'month_shop_points' => 0,
]);
}
$forecast = app(PaylineForecastService::class)->levelAndCommissionForecast($root, 7, 2026);
// Nur Annas 100 Punkte zaehlen -> zu 1000 fehlen 900 (nicht 1000 - 900 = 100).
expect($forecast['missing_pp'])->toBe(900.0)
->and($forecast['estimated_commission'])->toBe(10.0);
});
it('prognostiziert die Liefertage ausschliesslich aus den eigenen Kundenabos', function (): void {
// Kundenwunsch 2026-09-07: "wieviel Volumenpunkte habe ich am 5./10./20./25.
// Nur mit meinen Kundenabos" — ohne eigenes Abo, ohne Team.
$root = pflMakeUser();
$anna = pflMakeUser($root->id);
pflMakeAbo($root->id, 'ot', '2026-07-10', 30); // eigenes Kundenabo
pflMakeAbo($root->id, 'ot', '2026-07-25', 45); // eigenes Kundenabo
pflMakeAbo($root->id, 'me', '2026-07-10', 99); // eigenes Abo zaehlt NICHT
pflMakeAbo($anna->id, 'ot', '2026-07-05', 77); // Team zaehlt NICHT
pflMakeAbo($root->id, 'ot', '2026-08-05', 50); // naechster Monat zaehlt nicht
$forecast = app(PaylineForecastService::class)->personalCustomerAboForecast($root);
expect($forecast['base'])->toBe(0.0)
->and($forecast['days'])->toHaveCount(5)
->and($forecast['days'][0])->toMatchArray(['day' => 5, 'upcoming' => 0.0, 'total' => 0.0])
->and($forecast['days'][1])->toMatchArray(['day' => 10, 'upcoming' => 30.0, 'total' => 30.0])
->and($forecast['days'][2])->toMatchArray(['day' => 20, 'upcoming' => 30.0, 'total' => 30.0])
->and($forecast['days'][3])->toMatchArray(['day' => 25, 'upcoming' => 75.0, 'total' => 75.0]);
});
it('rechnet die bereits abgerechneten Kundenabo-Punkte taggenau in die persoenliche Vorschau ein', function (): void {
// Die Kacheln beantworten "wieviel Volumenpunkte habe ich am 5./10./20./25.".
// Jeder Termin zeigt den Stand AN DIESEM TAG, sonst enthaelt die Kachel zum 5.
// auch schon die Lieferungen vom 10. und laesst sich nicht nachrechnen.
Carbon::setTestNow(Carbon::parse('2026-07-11'));
$berater = pflMakeUser();
$aboOrder = ShoppingOrder::forceCreate([
'shopping_user_id' => 0, 'auth_user_id' => $berater->id, 'country_id' => 0,
'user_shop_id' => 0, 'is_abo' => true, 'paid' => true,
]);
$shopOrder = ShoppingOrder::forceCreate([
'shopping_user_id' => 0, 'auth_user_id' => $berater->id, 'country_id' => 0,
'user_shop_id' => 0, 'is_abo' => false, 'paid' => true,
]);
pflBookAboPoints($berater->id, $aboOrder->id, 40, '2026-07-05 04:10:00');
pflBookAboPoints($berater->id, $aboOrder->id, 60, '2026-07-10 04:10:00');
// Einzelbestellung eines Kunden: kein Abo, gehoert nicht in diese Kachel.
pflBookAboPoints($berater->id, $shopOrder->id, 500, '2026-07-10 04:10:00');
// Anderer Monat.
pflBookAboPoints($berater->id, $aboOrder->id, 900, '2026-06-10 04:10:00', 6, 2026);
// Noch offen zum 20.
pflMakeAbo($berater->id, 'ot', '2026-07-20', 30);
$forecast = app(PaylineForecastService::class)->personalCustomerAboForecast($berater);
expect($forecast['base'])->toBe(100.0)
->and($forecast['days'][0])->toMatchArray(['day' => 5, 'billed' => 40.0, 'upcoming' => 0.0, 'total' => 40.0])
->and($forecast['days'][1])->toMatchArray(['day' => 10, 'billed' => 100.0, 'upcoming' => 0.0, 'total' => 100.0])
->and($forecast['days'][2])->toMatchArray(['day' => 20, 'billed' => 100.0, 'upcoming' => 30.0, 'total' => 130.0])
->and($forecast['days'][3])->toMatchArray(['day' => 25, 'billed' => 100.0, 'upcoming' => 30.0, 'total' => 130.0]);
});
it('haengt Buchungen nach dem letzten Liefertag an die letzte Kachel', function (): void {
// Nachzuegler und manuelle Gutschriften duerfen nicht aus der Monatssumme
// fallen — sonst zeigt die letzte Kachel weniger als den Monatsstand.
Carbon::setTestNow(Carbon::parse('2026-07-29'));
$berater = pflMakeUser();
$aboOrder = ShoppingOrder::forceCreate([
'shopping_user_id' => 0, 'auth_user_id' => $berater->id, 'country_id' => 0,
'user_shop_id' => 0, 'is_abo' => true, 'paid' => true,
]);
pflBookAboPoints($berater->id, $aboOrder->id, 10, '2026-07-05 04:10:00');
pflBookAboPoints($berater->id, $aboOrder->id, 25, '2026-07-28 11:00:00');
$forecast = app(PaylineForecastService::class)->personalCustomerAboForecast($berater);
expect($forecast['days'][0]['total'])->toBe(10.0)
->and($forecast['days'][3]['total'])->toBe(10.0)
->and($forecast['days'][4]['total'])->toBe(35.0)
->and($forecast['base'])->toBe(35.0);
});
it('zieht ein Storno von der Basis der persoenlichen Vorschau ab', function (): void {
Carbon::setTestNow(Carbon::parse('2026-07-11'));
$berater = pflMakeUser();
$aboOrder = ShoppingOrder::forceCreate([
'shopping_user_id' => 0, 'auth_user_id' => $berater->id, 'country_id' => 0,
'user_shop_id' => 0, 'is_abo' => true, 'paid' => true,
]);
pflBookAboPoints($berater->id, $aboOrder->id, 100, '2026-07-05 04:10:00');
UserSalesVolume::forceCreate([
'user_id' => $berater->id, 'shopping_order_id' => $aboOrder->id, 'month' => 7, 'year' => 2026,
'points' => -40, 'status' => 6, 'storno_origin_status' => 2, 'created_at' => '2026-07-08 09:00:00',
]);
$forecast = app(PaylineForecastService::class)->personalCustomerAboForecast($berater);
expect($forecast['base'])->toBe(60.0)
->and($forecast['days'][1]['billed'])->toBe(60.0);
});
it('weist die abgerechneten Punkte je Abo aus, inklusive Einmal-Artikel', function (): void {
// Die Liste unter der Vorschau muss auf dieselbe Summe kommen. Einmal-Artikel
// werden nach der Ausfuehrung aus dem Abo entfernt, stehen also nicht mehr im
// wiederkehrenden Aboinhalt — gebucht wurden sie trotzdem.
Carbon::setTestNow(Carbon::parse('2026-07-11'));
$berater = pflMakeUser();
$abo = pflMakeAbo($berater->id, 'ot', '2026-08-05', 37);
$aboOrder = ShoppingOrder::forceCreate([
'shopping_user_id' => 0, 'auth_user_id' => $berater->id, 'country_id' => 0,
'user_shop_id' => 0, 'is_abo' => true, 'paid' => true,
]);
$abo->user_abo_orders()->create(['shopping_order_id' => $aboOrder->id, 'status' => 1, 'paid' => true]);
// Geliefert wurden 80: 37 wiederkehrend + 43 einmalig.
pflBookAboPoints($berater->id, $aboOrder->id, 80, '2026-07-05 04:10:00');
$svc = app(PaylineForecastService::class);
$byAbo = $svc->billedCustomerAboPointsByAbo($berater, 7, 2026);
expect($byAbo[$abo->id])->toBe(80.0)
->and($byAbo->sum())->toBe(80.0)
->and($svc->billedCustomerAboPoints($berater, 7, 2026))->toBe(80.0);
});
it('prognostiziert auch ohne bereits erreichte Qualifikationsstufe', function (): void {
// A08: Wer im Monat noch keine Stufe qualifiziert hat, kann die erste durch
// kommende eigene Kundenabo-Punkte trotzdem erreichen. Vorher lieferte die
// Prognose in diesem Zustand gar nichts.
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();
pflSeedContext($root, []);
UserBusiness::forceCreate([
'user_id' => $root->id,
'month' => 7,
'year' => 2026,
'version' => 1,
'payline_points' => 0.0,
'payline_points_qual_kp' => 0.0,
'sales_volume_points_KP_sum' => 0.0,
'commission_pp_total' => 0,
'commission_shop_sales' => 0,
'commission_growth_total' => 0,
'qual_user_level' => null, // noch keine Stufe erreicht
'qual_user_level_next' => [
'name' => 'Start', 'paylines' => 1, 'qual_pp' => 0, 'qual_kp' => 50, 'growth_bonus' => 0,
'pr_line_1' => 7, 'pr_line_2' => 0, 'pr_line_3' => 0, 'pr_line_4' => 0,
'pr_line_5' => 0, 'pr_line_6' => 0, 'pr_line_7' => 0, 'pr_line_8' => 0,
],
]);
// Eigenes Kundenabo mit 50 Punkten, faellig vor Monatsende
pflMakeAbo($root->id, 'ot', '2026-07-20', 50);
$forecast = app(PaylineForecastService::class)->levelAndCommissionForecast($root, 7, 2026);
expect($forecast)->not->toBeNull()
->and($forecast['next_name'])->toBe('Start')
->and($forecast['personal_forecast'])->toBe(50.0)
->and($forecast['missing_personal'])->toBe(0.0)
->and($forecast['reached'])->toBeTrue()
->and($forecast['estimated_level_name'])->toBe('Start');
});
it('liefert ohne erreichte und ohne naechste Stufe weiterhin keine Prognose', function (): void {
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();
pflSeedContext($root, []);
UserBusiness::forceCreate([
'user_id' => $root->id,
'month' => 7,
'year' => 2026,
'version' => 1,
'payline_points' => 0.0,
'sales_volume_points_KP_sum' => 0.0,
'qual_user_level' => null,
'qual_user_level_next' => null,
]);
expect(app(PaylineForecastService::class)->levelAndCommissionForecast($root, 7, 2026))->toBeNull();
});