K11 aus dem September-Protokoll (Tickets C1 und C2 im Entwicklungsplan), live seit 14.09.2026. C1 – Statistik rechnet wie die Provision - Standard-Baum ist der Provisionsbaum (Mitglieder wie TreeCalcBot: Mitgliedsebene, Abrechnungskonto, active_date; ausgefilterte Knoten kappen ihren Unterbaum, max. 21 Linien). Gesamte Downline bleibt per Umschalter erreichbar, Wahl in der Session. - Punkte-Definition: Provisionspunkte (TP + Shop), reine Karrierepunkte ausgewiesen statt eingerechnet, negative Monatssalden je User wie in der Provision auf 0 gekappt. - Abgeschlossene Monate nehmen die Mitglieder je Linie aus dem gespeicherten Provisionslauf (user_businesses), Snapshots je Baum-Definition (Payload-Version 3, Migration). - Struktur-Modal: Gesamt aus den Linien-Punkten mit Dezimalen statt der int-Spalte total_pp. - Messlauf statistics:compare-trees (Downline / Provisionsbaum / Struktur je VIP, Laufstand-Drift). C2 – Laufstand-Race (Provision zu niedrig bei zeitgleichen Buchungen) - Neuberechnung liest die Zeilen des Beraters sperrend (FOR UPDATE), Index (user_id, year, month). - Paid-Pfad der Payone- und Stripe-Webhooks als DB::transaction mit 3 Anlaeufen; Deadlock und Lock-Timeout aus der Rechnungserstellung werden nicht mehr geschluckt. - Sicherheitsnetz sales:repair-running-totals taeglich 02:45 vor dem Provisionslauf; abgeschlossene Monate werden nicht angefasst (Entscheid 14.09.: keine Nachzahlung fuer 06/08). Doku: Entwicklungsplan zum Protokoll, Deploy-Notiz 14.09. (inkl. Migrations-Stolperstein: Migrationen tragen Werte als Literale, nicht als Konstanten aus dem Anwendungscode). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
248 lines
13 KiB
PHP
248 lines
13 KiB
PHP
<?php
|
|
|
|
use App\Models\BackofficeStatisticsSnapshot;
|
|
use App\Models\UserBusiness;
|
|
use App\Models\UserBusinessStructure;
|
|
use App\Models\UserSalesVolume;
|
|
use App\Services\Backoffice\BackofficeDashboardService;
|
|
use App\Services\Backoffice\BackofficeDrilldownService;
|
|
use App\User;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* K11 (Kundenentscheid 2026-09-14): Die Team-Statistik rechnet standardmaessig mit
|
|
* dem Provisionsbaum und den Provisionspunkten — denselben Mitgliedern und
|
|
* Punkten wie die Team-Struktur. Die gesamte Downline bleibt per Umschalter
|
|
* erreichbar.
|
|
*/
|
|
uses(TestCase::class, RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
Carbon::setTestNow('2026-09-15 10:00:00');
|
|
});
|
|
|
|
afterEach(fn () => Carbon::setTestNow());
|
|
|
|
/**
|
|
* @param array<string, mixed> $overrides
|
|
*/
|
|
function sctUser(?int $sponsor = null, array $overrides = []): User
|
|
{
|
|
return User::forceCreate(array_merge([
|
|
'email' => 'sct-'.uniqid('', true).'@example.test',
|
|
'password' => bcrypt('secret'),
|
|
'lang' => 'de',
|
|
'm_level' => 1,
|
|
'm_sponsor' => $sponsor,
|
|
'active_date' => '2026-01-01',
|
|
'payment_account' => '2027-01-01',
|
|
], $overrides));
|
|
}
|
|
|
|
function sctVolume(User $user, float $points, int $status = 1, int $statusPoints = 1, int $month = 9): UserSalesVolume
|
|
{
|
|
return UserSalesVolume::forceCreate([
|
|
'user_id' => $user->id,
|
|
'month' => $month,
|
|
'year' => 2026,
|
|
'date' => sprintf('2026-%02d-05', $month),
|
|
'points' => $points,
|
|
'total_net' => $points,
|
|
'status' => $status,
|
|
'status_points' => $statusPoints,
|
|
'status_turnover' => 1,
|
|
]);
|
|
}
|
|
|
|
it('nimmt in den Provisionsbaum nur Berater mit Mitgliedsebene und Abrechnungskonto auf und kappt deren Unterbaum', function () {
|
|
$root = sctUser();
|
|
$partner = sctUser($root->id); // Linie 1, im Provisionsbaum
|
|
$noLevel = sctUser($root->id, ['m_level' => null]); // Linie 1, ohne Ebene → nicht im Provisionsbaum
|
|
$underNoLevel = sctUser($noLevel->id); // Linie 2, regulaer — aber unter gekapptem Knoten
|
|
$noAccount = sctUser($partner->id, ['payment_account' => null]); // Linie 2, ohne Abrechnungskonto
|
|
$expired = sctUser($partner->id, ['payment_account' => '2026-08-01']); // Linie 2, abgelaufen — zaehlt im Provisionsbaum trotzdem
|
|
$laterActive = sctUser($partner->id, ['active_date' => '2026-10-01']); // Linie 2, erst nach Monatsende aktiv
|
|
|
|
sctVolume($partner, 100);
|
|
sctVolume($noLevel, 100);
|
|
sctVolume($underNoLevel, 100);
|
|
sctVolume($noAccount, 100);
|
|
sctVolume($expired, 100);
|
|
sctVolume($laterActive, 100);
|
|
|
|
$service = app(BackofficeDashboardService::class);
|
|
|
|
$commission = $service->overview($root, 9, 2026);
|
|
$downline = $service->overview($root, 9, 2026, BackofficeDashboardService::TREE_DOWNLINE);
|
|
|
|
expect($commission['_meta']['tree'])->toBe('commission')
|
|
->and($commission['tree'])->toBe('commission')
|
|
->and($commission['lines'][1]['user_ids'])->toBe([$partner->id])
|
|
->and($commission['lines'][2]['user_ids'])->toBe([$expired->id])
|
|
->and($commission['lines'])->not->toHaveKey(3)
|
|
->and((float) $commission['totals']['total_points'])->toBe(200.0)
|
|
->and($commission['totals']['consultants'])->toBe(2);
|
|
|
|
expect($downline['_meta']['tree'])->toBe('downline')
|
|
->and(collect($downline['lines'][1]['user_ids'])->sort()->values()->all())->toBe(collect([$partner->id, $noLevel->id])->sort()->values()->all())
|
|
->and(count($downline['lines'][2]['user_ids']))->toBe(4)
|
|
->and((float) $downline['totals']['total_points'])->toBe(600.0);
|
|
});
|
|
|
|
it('rechnet Umsatz (Punkte) als Provisionspunkte: ohne Nur-KP-Buchungen und mit Knotenregel fuer negative Salden', function () {
|
|
$root = sctUser();
|
|
$a = sctUser($root->id);
|
|
$b = sctUser($root->id);
|
|
|
|
sctVolume($a, 300); // Eigenbestellung → TP
|
|
sctVolume($a, 50, 1, 2); // Neupartner-Einschreibung: nur KP → nicht in den Provisionspunkten
|
|
sctVolume($a, 120, 2); // Kundenbestellung → Shop
|
|
sctVolume($b, 80);
|
|
sctVolume($b, -100, 6); // Storno ueber den Monat hinaus → Saldo -20 → Knotenregel 0
|
|
|
|
$overview = app(BackofficeDashboardService::class)->overview($root, 9, 2026);
|
|
$line = $overview['lines'][1];
|
|
|
|
expect((float) $line['total_points'])->toBe(420.0)
|
|
->and((float) $line['own_points'])->toBe(330.0) // Aufteilung bleibt Zeilensumme: 300 + 50 + 80 - 100
|
|
->and((float) $line['external_points'])->toBe(120.0)
|
|
->and((float) $line['storno_floor_points'])->toBe(20.0)
|
|
->and((float) $overview['non_payline_points'])->toBe(50.0)
|
|
->and((float) $overview['storno_floor_points'])->toBe(20.0)
|
|
->and((float) $overview['totals']['total_points'])->toBe(420.0);
|
|
|
|
$details = app(BackofficeDrilldownService::class)->details($root, 0, 'total_points', 9, 2026);
|
|
$rowA = collect($details['rows'])->firstWhere('user_id', $a->id);
|
|
$rowB = collect($details['rows'])->firstWhere('user_id', $b->id);
|
|
|
|
expect($details['summary']['total_points'])->toBe(420.0)
|
|
->and($rowA['total_points'])->toBe(420.0)
|
|
->and($rowB['total_points'])->toBe(0.0)
|
|
->and($rowB['storno_floor_points'])->toBe(20.0)
|
|
->and($rowB['own_points'])->toBe(-20.0);
|
|
});
|
|
|
|
it('liefert die Provisionspunkte je Linie so, wie die Team-Struktur sie aus den Laufstaenden bildet', function () {
|
|
$root = sctUser();
|
|
$line1 = sctUser($root->id);
|
|
$line2 = sctUser($line1->id);
|
|
|
|
// Laufstaende wie SalesPointsVolume::reCalculateSalesPointsVolume() sie fuehren
|
|
// wuerde; getPointsTPSum() der letzten Zeile ist die Basis der Struktur.
|
|
UserSalesVolume::forceCreate(['user_id' => $line1->id, 'month' => 9, 'year' => 2026, 'date' => '2026-09-05', 'points' => 200, 'total_net' => 200, 'status' => 1, 'status_points' => 1, 'month_KP_points' => 200, 'month_TP_points' => 200, 'month_shop_points' => 0]);
|
|
UserSalesVolume::forceCreate(['user_id' => $line1->id, 'month' => 9, 'year' => 2026, 'date' => '2026-09-06', 'points' => 30, 'total_net' => 30, 'status' => 1, 'status_points' => 2, 'month_KP_points' => 230, 'month_TP_points' => 200, 'month_shop_points' => 0]);
|
|
UserSalesVolume::forceCreate(['user_id' => $line1->id, 'month' => 9, 'year' => 2026, 'date' => '2026-09-07', 'points' => 75.5, 'total_net' => 75.5, 'status' => 2, 'status_points' => 1, 'month_KP_points' => 230, 'month_TP_points' => 200, 'month_shop_points' => 75.5]);
|
|
UserSalesVolume::forceCreate(['user_id' => $line2->id, 'month' => 9, 'year' => 2026, 'date' => '2026-09-08', 'points' => 40, 'total_net' => 40, 'status' => 5, 'status_points' => 1, 'month_KP_points' => 40, 'month_TP_points' => 40, 'month_shop_points' => 0]);
|
|
|
|
$overview = app(BackofficeDashboardService::class)->overview($root, 9, 2026);
|
|
|
|
$lastRowOfLine1 = UserSalesVolume::where('user_id', $line1->id)->orderByDesc('id')->first();
|
|
$lastRowOfLine2 = UserSalesVolume::where('user_id', $line2->id)->orderByDesc('id')->first();
|
|
|
|
expect((float) $overview['lines'][1]['total_points'])->toBe((float) $lastRowOfLine1->getPointsTPSum())
|
|
->and((float) $overview['lines'][2]['total_points'])->toBe((float) $lastRowOfLine2->getPointsTPSum())
|
|
->and((float) $overview['totals']['total_points'])->toBe(315.5);
|
|
});
|
|
|
|
it('haelt je Baum-Definition einen eigenen Snapshot fuer abgeschlossene Monate', function () {
|
|
$root = sctUser();
|
|
$partner = sctUser($root->id);
|
|
$noLevel = sctUser($root->id, ['m_level' => null]);
|
|
|
|
sctVolume($partner, 100, 1, 1, 8);
|
|
sctVolume($noLevel, 100, 1, 1, 8);
|
|
|
|
$service = app(BackofficeDashboardService::class);
|
|
$service->storeSnapshot($root, 8, 2026, BackofficeDashboardService::TREE_COMMISSION);
|
|
$service->storeSnapshot($root, 8, 2026, BackofficeDashboardService::TREE_DOWNLINE);
|
|
|
|
expect(BackofficeStatisticsSnapshot::query()->where('user_id', $root->id)->count())->toBe(2)
|
|
->and(BackofficeStatisticsSnapshot::query()->where('user_id', $root->id)->pluck('tree')->sort()->values()->all())->toBe(['commission', 'downline']);
|
|
|
|
$noLevel->update(['m_level' => 1]);
|
|
|
|
$commission = $service->overview($root, 8, 2026);
|
|
$downline = $service->overview($root, 8, 2026, BackofficeDashboardService::TREE_DOWNLINE);
|
|
|
|
expect($commission['_meta']['source'])->toBe('snapshot')
|
|
->and((float) $commission['totals']['total_points'])->toBe(100.0)
|
|
->and($downline['_meta']['source'])->toBe('snapshot')
|
|
->and((float) $downline['totals']['total_points'])->toBe(200.0);
|
|
|
|
// Die Detailliste des eingefrorenen Monats folgt derselben Baum-Definition
|
|
// (die Berater-Liste selbst verlangt weiterhin eine Mitgliedsebene, deshalb
|
|
// hier ueber die Punkte-Liste).
|
|
$downlineDetails = app(BackofficeDrilldownService::class)->details($root, 0, 'total_points', 8, 2026, false, BackofficeDashboardService::TREE_DOWNLINE);
|
|
$commissionDetails = app(BackofficeDrilldownService::class)->details($root, 0, 'total_points', 8, 2026);
|
|
|
|
expect(collect($downlineDetails['rows'])->pluck('user_id')->sort()->values()->all())->toBe(collect([$partner->id, $noLevel->id])->sort()->values()->all())
|
|
->and(collect($commissionDetails['rows'])->pluck('user_id')->all())->toBe([$partner->id]);
|
|
});
|
|
|
|
it('faellt bei unbekannter Baum-Definition auf den Provisionsbaum zurueck', function () {
|
|
expect(BackofficeDashboardService::normalizeTree('foo'))->toBe('commission')
|
|
->and(BackofficeDashboardService::normalizeTree(null))->toBe('commission')
|
|
->and(BackofficeDashboardService::normalizeTree('downline'))->toBe('downline');
|
|
});
|
|
|
|
it('meldet im Messlauf Laufstand-Drift und repariert ihn auf Wunsch', function () {
|
|
$root = sctUser(null, ['admin' => 1]);
|
|
$partner = sctUser($root->id);
|
|
|
|
// Zwei Buchungen, deren Laufstaende sich ueberholt haben (zeitgleiche
|
|
// Abo-Ausfuehrungen): die letzte Zeile kennt die 129 Punkte der vorherigen nicht.
|
|
UserSalesVolume::forceCreate(['user_id' => $partner->id, 'month' => 9, 'year' => 2026, 'date' => '2026-09-10', 'points' => 129, 'total_net' => 129, 'status' => 2, 'status_points' => 1, 'month_KP_points' => 0, 'month_TP_points' => 0, 'month_shop_points' => 129]);
|
|
UserSalesVolume::forceCreate(['user_id' => $partner->id, 'month' => 9, 'year' => 2026, 'date' => '2026-09-10', 'points' => 51, 'total_net' => 51, 'status' => 2, 'status_points' => 1, 'month_KP_points' => 0, 'month_TP_points' => 0, 'month_shop_points' => 51]);
|
|
|
|
$this->artisan('statistics:compare-trees', ['month' => 9, 'year' => 2026, '--user' => [$root->id]])
|
|
->expectsOutputToContain('1 User mit Laufstand-Drift')
|
|
->assertSuccessful();
|
|
|
|
$this->artisan('statistics:compare-trees', ['month' => 9, 'year' => 2026, '--user' => [$root->id], '--repair-running-totals' => true])
|
|
->assertSuccessful();
|
|
|
|
$lastRow = UserSalesVolume::where('user_id', $partner->id)->orderByDesc('id')->first();
|
|
|
|
expect((float) $lastRow->month_shop_points)->toBe(180.0);
|
|
|
|
$this->artisan('statistics:compare-trees', ['month' => 9, 'year' => 2026, '--user' => [$root->id]])
|
|
->expectsOutputToContain('keine Abweichung')
|
|
->assertSuccessful();
|
|
});
|
|
|
|
it('nimmt fuer abgeschlossene Monate die Struktur des gespeicherten Provisionslaufs statt des heutigen Baums', function () {
|
|
$root = sctUser();
|
|
$stayed = sctUser($root->id); // damals wie heute unter root
|
|
$movedAway = sctUser(null); // damals unter root, heute deaktiviert (kein Sponsor)
|
|
$joinedLater = sctUser($root->id); // heute unter root, im Lauf vom 01.09. nicht im Baum
|
|
$grandChild = sctUser($movedAway->id); // damals unter movedAway (Linie 2)
|
|
|
|
sctVolume($stayed, 100, 1, 1, 8);
|
|
sctVolume($movedAway, 200, 1, 1, 8);
|
|
sctVolume($joinedLater, 400, 1, 1, 8);
|
|
sctVolume($grandChild, 50, 1, 1, 8);
|
|
|
|
$structure = UserBusinessStructure::create(['month' => 8, 'year' => 2026, 'completed' => true, 'status' => 1]);
|
|
$sponsorJson = fn (?int $sponsorId) => (object) ['is_sponsor' => $sponsorId !== null, 'user_id' => $sponsorId ?? false];
|
|
|
|
foreach ([[$root, null], [$stayed, $root->id], [$movedAway, $root->id], [$grandChild, $movedAway->id]] as [$user, $sponsorId]) {
|
|
UserBusiness::forceCreate(['user_id' => $user->id, 'month' => 8, 'year' => 2026, 'b_structure_id' => $structure->id, 'sponsor' => $sponsorJson($sponsorId), 'version' => 2]);
|
|
}
|
|
|
|
$service = app(BackofficeDashboardService::class);
|
|
$august = $service->overview($root, 8, 2026);
|
|
|
|
expect($august['tree_source'])->toBe('stored_structure')
|
|
->and(collect($august['lines'][1]['user_ids'])->sort()->values()->all())->toBe(collect([$stayed->id, $movedAway->id])->sort()->values()->all())
|
|
->and($august['lines'][2]['user_ids'])->toBe([$grandChild->id])
|
|
->and((float) $august['totals']['total_points'])->toBe(350.0);
|
|
|
|
// Der laufende Monat bleibt live (heutiger Baum), auch wenn ein Nachtlauf existiert.
|
|
UserBusinessStructure::create(['month' => 9, 'year' => 2026, 'completed' => true, 'status' => 1]);
|
|
$september = $service->overview($root, 9, 2026);
|
|
|
|
expect($september['tree_source'])->toBe('live')
|
|
->and(collect($september['lines'][1]['user_ids'])->sort()->values()->all())->toBe(collect([$stayed->id, $joinedLater->id])->sort()->values()->all());
|
|
});
|