Wer Ende August registriert und am 1. September bezahlt hat, tauchte in keiner
Neupartner-Zaehlung auf: Der August-Snapshot wurde um 04:45 eingefroren, da
fehlte noch das Abrechnungskonto; im September lag active_date im falschen
Monat. Betroffen sind 2026 acht Partner, auch im Recruiting ihrer Sponsoren.
Kundenentscheidung 16.09.2026: der Zeitpunkt der Zahlung ist massgeblich.
Neue Spalte users.partner_paid_at, gesetzt bei der ersten Kontozahlung
(Payment::paymentStatusPaidAction) und bei manueller Freischaltung durch den
Admin — nur, solange sie leer ist; Verlaengerung und Wiederherstellung aendern
sie nicht. Bestehende Partner werden per Migration aus der ersten bezahlten
Registrierungsbestellung rueckgefuellt (Zeitstempel der Punktebuchung, ersatzweise
Bestelldatum, ohne Bestellung das Aktivierungsdatum). Bewusst nicht
payment_account minus ein Jahr: das ergaebe bei Verlaengerern den Jahrestag der
Erstzahlung.
User::scopeNewPartnerIn() und isNewPartnerIn() lesen jetzt partner_paid_at —
damit Kachel, Recruiter-Ranking, Top 3 und Neupartner-Liste gleichermassen.
Die Liste und der CSV-Export zeigen "Bezahlt am" neben "Registriert am".
Unveraendert: die Teamstruktur haengt weiter an active_date (auch im
Provisionsbaum). Wer ist neu dazugekommen und wer gehoert zur Linie sind zwei
verschiedene Fragen.
Die eingefrorenen Monate Januar bis August enthalten sieben dieser Partner
nicht; ob sie per --force neu gebaut werden, ist eine Entscheidung — siehe
dev/2026-07-22/neupartner-zahlungsmonat.md.
Mit drin: die handgebaute SQLite-Tabelle in BackofficeDashboardServiceTest
kannte status_reason aus d6efe56 noch nicht.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
702 lines
30 KiB
PHP
702 lines
30 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('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();
|
|
});
|