mivita/tests/Feature/PaylineForecastServiceTest.php
Kevin 4062c46e52 Kundenabo-Vorschau: bereits abgerechnete Punkte fehlten in der Basis
Die Kachelreihe "Punkte aus meinen Kundenabos zum 5./10./20./25." startete bei
einer Basis von 0 und addierte nur die noch offenen Ausfuehrungen. Damit standen
die bereits vergangenen Termine auf null, obwohl im Monat laengst Lieferungen
gebucht waren, und die spaeteren Termine zeigten nur den Rest statt des
Monatsstands.

Berater 454 im September: vorher 0 / 0 / 131 / 319, jetzt 457 / 457 / 588 / 776.
Betrifft im September 86 Berater mit zusammen 15.936 Punkten — reine Anzeige,
gebucht und abgerechnet wurde korrekt.

Die Basis ist jetzt die im Monat aus den eigenen Kundenabos abgerechnete
Punktzahl, ueber dieselbe Abgrenzung wie die Backoffice-Statistik
(SalesVolumePoints::customerAboPoints(), Stornos vorzeichengetreu). Damit liest
sich die Kachel wie die Prognose-Kachel auf dem Dashboard: Stand heute plus das,
was bis zum Termin noch hereinkommt. Hinweistext in allen vier Sprachen
nachgezogen.

Nicht aus dem Audit-Paket: Die Null-Basis stammt aus der K9-Umsetzung vom 07.09.,
nicht aus der A13-Korrektur.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 07:36:13 +00:00

616 lines
27 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,
]);
}
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, ?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(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('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('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(4)
->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 in die persoenliche Vorschau ein', function (): void {
// Die Kacheln beantworten "wieviel Volumenpunkte habe ich am 5./10./20./25.".
// Ohne die bereits gebuchten Lieferungen stehen die vergangenen Termine auf
// null, obwohl die Punkte laengst im Ist stecken.
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,
]);
// Am 5. und 10. geliefert und gebucht.
UserSalesVolume::forceCreate(['user_id' => $berater->id, 'shopping_order_id' => $aboOrder->id, 'month' => 7, 'year' => 2026, 'points' => 40, 'status' => 2]);
UserSalesVolume::forceCreate(['user_id' => $berater->id, 'shopping_order_id' => $aboOrder->id, 'month' => 7, 'year' => 2026, 'points' => 60, 'status' => 2]);
// Einzelbestellung eines Kunden: kein Abo, gehoert nicht in diese Kachel.
UserSalesVolume::forceCreate(['user_id' => $berater->id, 'shopping_order_id' => $shopOrder->id, 'month' => 7, 'year' => 2026, 'points' => 500, 'status' => 2]);
// Anderer Monat.
UserSalesVolume::forceCreate(['user_id' => $berater->id, 'shopping_order_id' => $aboOrder->id, 'month' => 6, 'year' => 2026, 'points' => 900, 'status' => 2]);
// 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, 'upcoming' => 0.0, 'total' => 100.0])
->and($forecast['days'][2])->toMatchArray(['day' => 20, 'upcoming' => 30.0, 'total' => 130.0])
->and($forecast['days'][3])->toMatchArray(['day' => 25, 'upcoming' => 30.0, 'total' => 130.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,
]);
UserSalesVolume::forceCreate(['user_id' => $berater->id, 'shopping_order_id' => $aboOrder->id, 'month' => 7, 'year' => 2026, 'points' => 100, 'status' => 2]);
UserSalesVolume::forceCreate(['user_id' => $berater->id, 'shopping_order_id' => $aboOrder->id, 'month' => 7, 'year' => 2026, 'points' => -40, 'status' => 6, 'storno_origin_status' => 2]);
expect(app(PaylineForecastService::class)->personalCustomerAboForecast($berater)['base'])->toBe(60.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();
});