mivita/tests/Feature/PaylineForecastServiceTest.php
Kevin 685f7a90b9 Kundenabo-Seite: Vorschau taggenau, Liste mit den tatsaechlich gebuchten Punkten
Vorschau und Liste liessen sich nicht gegeneinander nachrechnen — zwei Ursachen,
beide hier behoben.

1. Die Vorschau nahm eine pauschale Monatssumme als Basis. Damit enthielt die
   Kachel zum 5. auch schon die Lieferungen vom 10. Jetzt zaehlt je Termin, was
   bis zu diesem Tag gebucht war, plus die bis dahin offenen Lieferungen.
   Buchungen nach dem letzten Liefertag (Nachzuegler, manuelle Gutschriften)
   haengen sich an die letzte Kachel, damit die den Monatsstand zeigt. Jede
   Kachel weist ihre Aufschluesselung aus ("457 abgerechnet + 131 offen").

   Beraterin 454 im September: 132 / 457 / 588 / 776 statt 0 / 0 / 131 / 319.

2. Die Liste zeigte nur den wiederkehrenden Abo-Inhalt. Einmal-Artikel und
   sonstige Zubuchungen einer Lieferung werden nach der Ausfuehrung aus dem Abo
   entfernt und tauchten deshalb nirgends mehr auf — bei Abo 266 waren das 43
   Punkte, bei Abo 418 rund 39. Neue Spalte "Diesen Monat" mit den je Abo
   tatsaechlich gebuchten Punkten, bei Abweichung mit dem Zusatz "+43 einmalig",
   dazu eine Summenzeile. Ein nicht zuordenbarer Rest (geloeschtes Abo, Buchung
   ohne Ausfuehrungsbezug) wird gesondert ausgewiesen, damit die Addition aufgeht.

   Die Spalte erscheint nur ohne Monatsfilter; fuer einen vergangenen Monat waere
   sie irrefuehrend.

Summe der Monatsspalte = Basis der letzten Kachel; Summe der Abo-Punkte der Abos
mit Termin 20./25. = deren offener Teil.

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

682 lines
29 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 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(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 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(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();
});