mivita/tests/Feature/PaylineForecastServiceTest.php
Kevin 94e2b13384 Statistik: uebrige Audit-Korrekturen (Abo-Regeln, Prognose, Sortierung)
A01 — Abo-Regeln waren ueber das Aktiv-Feld umgehbar. Das
Selbstbedienungsformular hat gar keinen Aktiv-Schalter, es spiegelt den Zustand
nur in einem versteckten Feld zurueck; `updateStatus()` hat den Wert trotzdem
uebernommen. Wer den Request manipulierte, konnte sein eigenes Abo dauerhaft
deaktivieren und so die Mindestlaufzeit umgehen, am Pausenpfad mit seinen
Sperren vorbei. Das Feld wirkt jetzt nur noch im Admin-Pfad.

A16 — Statuswechsel wurden nur aus dem Formular protokolliert. Recovery-Zahlung,
Retry, externe Erledigung und die DSGVO-Anonymisierung schreiben `active` direkt
auf das Modell. Das ist heikel, weil die historische Bestandsrueckrechnung ein
Abo verwirft, solange eine protokollierte Deaktivierung nicht durch eine
spaetere protokollierte Reaktivierung aufgehoben ist — ein stiller
`active = true` nimmt es dauerhaft aus allen Folgemonaten.

Der Eintrag liegt deshalb jetzt am Model (`UserAbo::logStatusTransition()`) und
nicht in den einzelnen Diensten: so kann ihn ein kuenftiger Schreibpfad nicht
vergessen, und er entsteht in derselben Transaktion wie die Statusaenderung.
Nebeneffekt: Die DSGVO-Anonymisierung setzt `active = false` ohne `cancel_date`
— bisher fiel ein so abgeschaltetes Abo rueckwirkend aus allen vergangenen
Monaten, jetzt bleibt die Historie korrekt.

A13 — Ein heute faelliges Abo galt ab Mitternacht als erledigt, der
Ausfuehrungsjob laeuft aber erst um 04:00. In diesem Fenster fehlten die Punkte
in der Prognose UND im Ist. Der neue Scope `UserAbo::scopeOpenExecutionUntil()`
zaehlt den heutigen Liefertag mit, solange keine Ausfuehrung angelegt ist —
genau diese Bedingung verhindert die Doppelzaehlung. Alle drei Prognosen nutzen
jetzt denselben Scope statt je einer eigenen Kopie der Datumsregel.

A11 — Der Namens-Tiebreak der Top 3 lief nach dem Abschneiden auf 25
Kandidaten, bei Gleichstand entschied also die Reihenfolge der Abfrage. Die
Auswahlgrenze wird jetzt um alle Gleichstaende an dieser Grenze erweitert, und
Kandidaten ohne Account fallen vorher weg statt die Plaetze zu blockieren. Als
letzte Regel entscheidet die user_id, damit eine gecachte Rangliste
reproduzierbar bleibt.

A14 — Sortiert wurde ueber den angezeigten Zellentext. Unter `en` liefert die
Anwendung `Y-m-d` und `1,000.00`, damit ergab jedes Datum 0 und `1,000.00` wurde
zur 1. Die Zellen tragen jetzt `data-sort-value` mit dem unformatierten Wert;
ohne Attribut gilt weiter der Textweg. Dabei mitgenommen: Umlaute landeten hinter
dem Z (jetzt localeCompare), leere Werte lagen als 0 zwischen den negativen
Stornowerten, und `next_date` wurde doppelt formatiert.

Ausserdem A08 (Prognose auch ohne erreichte Qualifikationsstufe), A09
(Summenzeile im Neupartner-CSV), A10 (Lieferungszahl vorgeladen), A17
(Faelligkeitsgruppen ohne Monatsueberlauf), A18 (Formelinhalte im CSV als Text)
und A19 (fehlgeschlagene Provisionsberechnung ist als solche erkennbar statt
0,00 EUR).

Audit-Befunde A01, A08, A09, A10, A11, A13, A14, A16, A17, A18, A19.

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

565 lines
24 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('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();
});