mivita/tests/Feature/SalesVolume/SponsorBuyingPointsRepairTest.php
Kevin 867dc21b57 Statistiken: Kundenfeedback 2026-08-19 komplett (N1-N8) + Review-Fixes + VP-/Incentive-Repair gehärtet
Statistik-Änderungen 2026-08-19 (dev/2026-07-22/entwicklungsplan-2026-08-19.md, alle 8 Pakete gemäß beantworteten F-Fragen):
- N1 Monatsstatistik: Team-Cluster (Firstlines klickbar -> neue Seite user_team_firstlines, Teampartner gesamt/Payline [nur aktive], Abos Payline mit +N) + Level-Kachel (Quali-Stufe, fehlende Payline-/persönliche Punkte, Karriere-Level); LiveCommissionService-Snapshot v3 reicht Level-/Quali-Daten durch
- N2 Abo-Kacheln auf Home monatsbezogen (aboStockCount-Rückrechnung über cancel/deleted/Status-Historie; VIP-Statistik bleibt tagesaktuell)
- N3 VIP-Statistik 'Recruiter des Monats' (Neupartner-Definition wie KPI, rekursives Ketten-Volumen als Gesamtpunkte inkl. Starterpaket) + Drilldown/CSV-Spalte 'Punkte gesamt'
- N4 Prognose-Block 'Level & Provision laut Prognose (Stand 25.)' mit unverbindlich-Kennzeichnung (ebenengenaue offene Abo-Punkte + TP-Laufstände)
- N5 Checkout-Herkunft: Dropdown raus, Pflicht-Freitext (lokalisierte Fehlermeldung)
- N6 Berater-Profilfoto (user_accounts.profile_image, Einwilligung als Sprachkey-Platzhalter, DSGVO-Löschung) + teaminterne Top-3-Karte (TopResultsService, Scope-Schalter)
- N7 USt-Optionen: 'nicht umsatzsteuerpflichtig' zuerst + '§ 19 UStG'-Textfix
- N8 Jahresgebühr-Nachfassliste je Berater (AnnualFeeDueService, überfällig/1-3 Monate, Sidenav)

Kontroll-Review (8 Perspektiven) angewendet: next-Level-Fallback im Steady-State (resolveNextLevel — sonst 'Höchstes Level erreicht' für Normalfälle), _calculated-Punktebasis bei mehr Paylines, keine Wiederbelebung deaktivierter Abos in der Monatsrückrechnung, Payline-Abo-Kachel monatskonsistent, Top-3-Nachrücker; Performance: BackofficeDashboardService als Singleton mit lineBuckets-Memo (+flushLineBucketsMemo), level_forecast 6h-Cache; Reuse: User-Scopes firstlinesOf/newPartnerIn, SalesVolumePoints::totalPointsByUser, Slim::storeCroppedImage (3 Upload-Kopien vereinigt), activeAbo-Scope statt Inline-Prädikate, Form::open-Konvention, Return-Types

Repair-Härtung (Kundenentscheide): VP-Nachtrepair dedupliziert über shopping_order_id (Live-Pfad schreibt sie jetzt mit), zählende Alt-Zeilen-Erkennung, Vormonats-Scan für Monatswechsel, Firmenaccount-Buchungen (config mivita.company_user_id) werden automatisch auf den Berater umgebucht (in place bzw. per Gegenbuchung bei geschlossenem Monat; Echtfall Anna Schall Order 58797 -> Christina Schuder), Berater-zu-Berater nur Warnung; Incentive-Repair: m_sponsor=NULL löscht nicht mehr, FK-Repair vor Stale-Bereinigung, gebatchte Lookups, zentrales Incentive::qualificationWindow(); .env aus dem Git-Tracking entfernt (Pflege per SFTP)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 10:44:44 +00:00

433 lines
16 KiB
PHP

<?php
use App\Models\Country;
use App\Models\Product;
use App\Models\Shipping;
use App\Models\ShippingCountry;
use App\Models\ShoppingOrder;
use App\Models\ShoppingOrderItem;
use App\Models\ShoppingUser;
use App\Models\UserSalesVolume;
use App\Models\UserShop;
use App\Services\Payment;
use App\Services\SponsorBuyingPointsRepairService;
use App\User;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
beforeEach(function () {
Carbon::setTestNow(Carbon::parse('2026-08-12 12:00:00'));
});
afterEach(function () {
Carbon::setTestNow();
});
function vpRepairCreateUser(?int $sponsorId = null, string $firstName = 'Test', string $lastName = 'User'): User
{
$user = User::forceCreate([
'email' => 'vp-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'm_sponsor' => $sponsorId,
]);
$accountId = DB::table('user_accounts')->insertGetId([
'first_name' => $firstName,
'last_name' => $lastName,
'created_at' => now(),
'updated_at' => now(),
]);
$user->account_id = $accountId;
$user->save();
return $user->fresh();
}
function vpRepairCreateVpProduct(int $vpPoints = 200): Product
{
$id = DB::table('products')->insertGetId([
'name' => 'Starterkit '.uniqid(),
'title' => 'T',
'is_membership_only' => false,
'sponsor_buying_points' => true,
'sponsor_buying_points_amount' => $vpPoints,
'active' => true,
'created_at' => now(),
'updated_at' => now(),
]);
return Product::findOrFail($id);
}
function vpRepairCreatePaidOrder(User $buyer, User $shopOwner, Product $product, bool $paid = true, ?string $createdAt = null): ShoppingOrder
{
$country = Country::firstOrCreate(['code' => 'DE'], [
'phone' => '49',
'en' => 'Germany',
'de' => 'Deutschland',
'es' => 'Alemania',
'fr' => 'Allemagne',
'it' => 'Germania',
'ru' => 'Германия',
]);
$shipping = Shipping::create(['name' => 'S', 'active' => true]);
$shippingCountry = ShippingCountry::create(['shipping_id' => $shipping->id, 'country_id' => $country->id]);
$userShop = UserShop::create([
'user_id' => $shopOwner->id,
'name' => 'T'.substr(md5(uniqid('', true)), 0, 10),
'slug' => 't-'.substr(md5(uniqid('', true)), 0, 10),
'active' => true,
]);
$shoppingUser = ShoppingUser::create([
'auth_user_id' => $buyer->id,
'member_id' => $shopOwner->id,
'billing_country_id' => $country->id,
'shipping_country_id' => $country->id,
'billing_email' => 'su-'.uniqid('', true).'@example.com',
'is_from' => 'wizard',
]);
$order = ShoppingOrder::create([
'shopping_user_id' => $shoppingUser->id,
'auth_user_id' => $buyer->id,
'country_id' => $shippingCountry->id,
'user_shop_id' => $userShop->id,
'payment_for' => 1,
'paid' => $paid,
'txaction' => $paid ? 'paid' : 'appointed',
'mode' => 'test',
'total' => 100,
'subtotal' => 90,
]);
ShoppingOrderItem::create([
'shopping_order_id' => $order->id,
'product_id' => $product->id,
'qty' => 1,
'price' => 50,
]);
if ($createdAt) {
$order->created_at = Carbon::parse($createdAt);
$order->save();
}
return $order->fresh();
}
it('bucht fehlende VP-Punkte des Sponsors für bezahlte Bestellungen des Monats nach', function () {
$sponsor = vpRepairCreateUser(null, 'Jasmin', 'Grund');
$buyer = vpRepairCreateUser($sponsor->id, 'Madeleine', 'Schrade');
$product = vpRepairCreateVpProduct(200);
$order = vpRepairCreatePaidOrder($buyer, $sponsor, $product);
$result = app(SponsorBuyingPointsRepairService::class)->repair(8, 2026);
expect($result['created'])->toHaveCount(1);
$row = UserSalesVolume::where('user_id', $sponsor->id)->where('status', 5)->first();
expect($row)->not->toBeNull();
expect((int) $row->points)->toBe(200);
expect((int) $row->status_points)->toBe(2);
expect((int) $row->month)->toBe(8);
expect((int) $row->year)->toBe(2026);
expect($row->shopping_order_id)->toBe($order->id);
expect($row->info)->toBe('VP: Madeleine Schrade | '.$product->name);
});
it('ist idempotent - zweiter Lauf bucht nichts doppelt', function () {
$sponsor = vpRepairCreateUser();
$buyer = vpRepairCreateUser($sponsor->id);
vpRepairCreatePaidOrder($buyer, $sponsor, vpRepairCreateVpProduct());
$service = app(SponsorBuyingPointsRepairService::class);
$service->repair(8, 2026);
$second = $service->repair(8, 2026);
expect($second['created'])->toHaveCount(0);
expect($second['skipped_existing'])->toBe(1);
expect(UserSalesVolume::where('user_id', $sponsor->id)->where('status', 5)->count())->toBe(1);
});
it('erkennt vorhandene Live-Buchungen (ohne shopping_order_id) und bucht nicht doppelt', function () {
$sponsor = vpRepairCreateUser(null, 'Steffi', 'Karrer');
$buyer = vpRepairCreateUser($sponsor->id, 'Dino', 'Portaro');
$product = vpRepairCreateVpProduct(200);
vpRepairCreatePaidOrder($buyer, $sponsor, $product);
// Live-Zeile wie Payment::addSponsorBuyingPoints (shopping_order_id = null)
UserSalesVolume::create([
'user_id' => $sponsor->id,
'shopping_order_id' => null,
'month' => 8,
'year' => 2026,
'points' => 200,
'status_points' => 2,
'total_net' => 0,
'info' => 'VP: Dino Portaro | '.$product->name,
'status' => 5,
]);
$result = app(SponsorBuyingPointsRepairService::class)->repair(8, 2026);
expect($result['created'])->toHaveCount(0);
expect($result['skipped_existing'])->toBe(1);
expect(UserSalesVolume::where('user_id', $sponsor->id)->where('status', 5)->count())->toBe(1);
});
it('ignoriert unbezahlte Bestellungen und Käufer ohne Sponsor', function () {
$sponsor = vpRepairCreateUser();
$product = vpRepairCreateVpProduct();
$buyerUnpaid = vpRepairCreateUser($sponsor->id);
vpRepairCreatePaidOrder($buyerUnpaid, $sponsor, $product, paid: false);
$buyerNoSponsor = vpRepairCreateUser(null);
vpRepairCreatePaidOrder($buyerNoSponsor, $sponsor, $product);
$result = app(SponsorBuyingPointsRepairService::class)->repair(8, 2026);
expect($result['created'])->toHaveCount(0);
expect(UserSalesVolume::where('status', 5)->count())->toBe(0);
});
it('lässt Bestellungen aus anderen Monaten unangetastet', function () {
$sponsor = vpRepairCreateUser();
$buyer = vpRepairCreateUser($sponsor->id);
vpRepairCreatePaidOrder($buyer, $sponsor, vpRepairCreateVpProduct(), createdAt: '2026-06-19 09:32:05');
$result = app(SponsorBuyingPointsRepairService::class)->repair(8, 2026);
expect($result['checked'])->toBe(0);
expect(UserSalesVolume::where('status', 5)->count())->toBe(0);
});
it('bucht im dry-run nichts, listet aber die Kandidaten', function () {
$sponsor = vpRepairCreateUser();
$buyer = vpRepairCreateUser($sponsor->id);
vpRepairCreatePaidOrder($buyer, $sponsor, vpRepairCreateVpProduct());
$result = app(SponsorBuyingPointsRepairService::class)->repair(8, 2026, dryRun: true);
expect($result['created'])->toHaveCount(1);
expect(UserSalesVolume::where('status', 5)->count())->toBe(0);
});
it('verweigert Buchungen in fremde Monate ohne --force', function () {
$this->artisan('sales:repair-sponsor-vp', ['--month' => 6, '--year' => 2026])
->assertFailed();
$this->artisan('sales:repair-sponsor-vp', ['--month' => 6, '--year' => 2026, '--dry-run' => true])
->assertSuccessful();
});
it('heilt den Monatswechsel: Vormonats-Bestellung ohne Buchung wird in den Zielmonat nachgebucht', function () {
$sponsor = vpRepairCreateUser(null, 'Sina', 'Sponsor');
$buyer = vpRepairCreateUser($sponsor->id, 'Britta', 'Boundary');
$product = vpRepairCreateVpProduct(200);
vpRepairCreatePaidOrder($buyer, $sponsor, $product, true, '2026-07-31 23:50:00');
$result = app(SponsorBuyingPointsRepairService::class)->repair(8, 2026);
expect($result['created'])->toHaveCount(1);
$row = UserSalesVolume::where('user_id', $sponsor->id)->where('status', 5)->first();
expect((int) $row->month)->toBe(8)->and((int) $row->year)->toBe(2026);
});
it('erkennt Alt-Zeilen zaehlend: zwei gleiche Bestellungen, eine Alt-Buchung - die zweite wird nachgebucht', function () {
$sponsor = vpRepairCreateUser(null, 'Sonja', 'Sponsor');
$buyer = vpRepairCreateUser($sponsor->id, 'Doppel', 'Besteller');
$product = vpRepairCreateVpProduct(200);
vpRepairCreatePaidOrder($buyer, $sponsor, $product);
vpRepairCreatePaidOrder($buyer, $sponsor, $product);
// Alt-Zeile aus dem frueheren Live-Pfad: ohne Order-Referenz, gleicher info-Text
UserSalesVolume::forceCreate([
'user_id' => $sponsor->id,
'shopping_order_id' => null,
'month' => 8,
'year' => 2026,
'points' => 200,
'status_points' => 2,
'status' => 5,
'info' => 'VP: Doppel Besteller | '.$product->name,
]);
$result = app(SponsorBuyingPointsRepairService::class)->repair(8, 2026);
expect($result['created'])->toHaveCount(1)
->and($result['skipped_existing'])->toBe(1)
->and(UserSalesVolume::where('user_id', $sponsor->id)->where('status', 5)->count())->toBe(2);
});
it('ist gegen Umbenennungen immun, wenn die Live-Buchung eine Order-Referenz traegt', function () {
$sponsor = vpRepairCreateUser(null, 'Rita', 'Sponsor');
$buyer = vpRepairCreateUser($sponsor->id, 'Madelaine', 'Schrade');
$product = vpRepairCreateVpProduct(200);
$order = vpRepairCreatePaidOrder($buyer, $sponsor, $product);
// Live-Buchung MIT Order-Referenz, info mit altem (Tippfehler-)Namen
UserSalesVolume::forceCreate([
'user_id' => $sponsor->id,
'shopping_order_id' => $order->id,
'month' => 8,
'year' => 2026,
'points' => 200,
'status_points' => 2,
'status' => 5,
'info' => 'VP: Madelaine Schrade | '.$product->name,
]);
// Name wird korrigiert - der info-Text passt nicht mehr, die Order-Referenz schon
$buyer->account->forceFill(['first_name' => 'Madeleine'])->save();
$result = app(SponsorBuyingPointsRepairService::class)->repair(8, 2026);
expect($result['created'])->toHaveCount(0)
->and($result['skipped_existing'])->toBe(1)
->and(UserSalesVolume::where('user_id', $sponsor->id)->where('status', 5)->count())->toBe(1);
});
it('schreibt im Live-Pfad die Order-Referenz mit', function () {
// Test-Schema hinkt der Produktion hinterher: status_turnover ist dort nullable
Schema::table('user_sales_volumes', function ($table) {
$table->integer('status_turnover')->nullable()->change();
});
$sponsor = vpRepairCreateUser(null, 'Lena', 'Live');
$buyer = vpRepairCreateUser($sponsor->id, 'Nora', 'Neu');
$product = vpRepairCreateVpProduct(400);
$order = vpRepairCreatePaidOrder($buyer, $sponsor, $product);
Payment::addSponsorBuyingPoints($buyer->fresh(), $product, $order);
$row = UserSalesVolume::where('user_id', $sponsor->id)->where('status', 5)->first();
expect($row)->not->toBeNull()
->and($row->shopping_order_id)->toBe($order->id)
->and((int) $row->points)->toBe(400);
// Repair danach: nichts doppelt
$result = app(SponsorBuyingPointsRepairService::class)->repair(8, 2026);
expect($result['created'])->toHaveCount(0);
});
it('bucht nach Umhaengung nicht doppelt, sondern meldet den anderen Sponsor', function () {
$oldSponsor = vpRepairCreateUser(null, 'Alte', 'Firma');
$newSponsor = vpRepairCreateUser(null, 'Christina', 'Schuder');
$buyer = vpRepairCreateUser($oldSponsor->id, 'Anna', 'Schall');
$product = vpRepairCreateVpProduct(200);
vpRepairCreatePaidOrder($buyer, $oldSponsor, $product);
// Live-Buchung lief beim ALTEN Sponsor (ohne Order-Referenz, Vormonat)
UserSalesVolume::forceCreate([
'user_id' => $oldSponsor->id,
'shopping_order_id' => null,
'month' => 7,
'year' => 2026,
'points' => 200,
'status_points' => 2,
'status' => 5,
'info' => 'VP: Anna Schall | '.$product->name,
]);
// Danach wurde Anna umgehaengt
$buyer->forceFill(['m_sponsor' => $newSponsor->id])->save();
$result = app(SponsorBuyingPointsRepairService::class)->repair(8, 2026);
expect($result['created'])->toHaveCount(0)
->and($result['skipped_existing'])->toBe(1)
->and($result['skipped_other_sponsor'])->toHaveCount(1)
->and($result['skipped_other_sponsor'][0]['booked_sponsor_id'])->toBe($oldSponsor->id)
->and($result['skipped_other_sponsor'][0]['current_sponsor_id'])->toBe($newSponsor->id)
->and(UserSalesVolume::where('status', 5)->count())->toBe(1);
});
it('bucht VP-Punkte vom Firmenaccount automatisch auf den Berater um (Zielmonat: in place)', function () {
config(['mivita.company_user_id' => 999999]);
$company = vpRepairCreateUser(null, 'mivita', 'care gmbh');
$company->forceFill(['id' => 999999])->save();
$company = User::find(999999);
$sponsor = vpRepairCreateUser(null, 'Christina', 'Schuder');
$buyer = vpRepairCreateUser($sponsor->id, 'Anna', 'Schall');
$product = vpRepairCreateVpProduct(200);
$order = vpRepairCreatePaidOrder($buyer, $sponsor, $product);
// Live-Buchung landete beim Firmenaccount (gleicher Monat, ohne Order-Referenz)
UserSalesVolume::forceCreate([
'user_id' => $company->id,
'shopping_order_id' => null,
'month' => 8,
'year' => 2026,
'points' => 200,
'status_points' => 2,
'status' => 5,
'info' => 'VP: Anna Schall | '.$product->name,
]);
$result = app(SponsorBuyingPointsRepairService::class)->repair(8, 2026);
expect($result['rebooked'])->toHaveCount(1)
->and($result['created'])->toHaveCount(0)
->and($result['skipped_other_sponsor'])->toHaveCount(0);
// Genau EINE Zeile, jetzt beim Berater und mit Order-Referenz
expect(UserSalesVolume::where('status', 5)->count())->toBe(1);
$row = UserSalesVolume::where('status', 5)->first();
expect($row->user_id)->toBe($sponsor->id)
->and($row->shopping_order_id)->toBe($order->id)
->and((int) $row->points)->toBe(200);
});
it('bucht aus abgeschlossenem Monat per Gegenbuchung um, ohne den alten Monat anzufassen', function () {
config(['mivita.company_user_id' => 999999]);
$company = vpRepairCreateUser(null, 'mivita', 'care gmbh');
$company->forceFill(['id' => 999999])->save();
$company = User::find(999999);
$sponsor = vpRepairCreateUser(null, 'Christina', 'Schuder');
$buyer = vpRepairCreateUser($sponsor->id, 'Anna', 'Schall');
$product = vpRepairCreateVpProduct(200);
vpRepairCreatePaidOrder($buyer, $sponsor, $product, true, '2026-07-31 23:50:00');
// Live-Buchung beim Firmenaccount im (abgeschlossenen) Juli
$julyRow = UserSalesVolume::forceCreate([
'user_id' => $company->id,
'shopping_order_id' => null,
'month' => 7,
'year' => 2026,
'points' => 200,
'status_points' => 2,
'status' => 5,
'info' => 'VP: Anna Schall | '.$product->name,
]);
$result = app(SponsorBuyingPointsRepairService::class)->repair(8, 2026);
expect($result['rebooked'])->toHaveCount(1);
// Juli-Zeile unangetastet
expect($julyRow->fresh()->user_id)->toBe($company->id)
->and((int) $julyRow->fresh()->points)->toBe(200)
->and((int) $julyRow->fresh()->month)->toBe(7);
// August: +200 beim Berater (mit Order-Referenz), -200 beim Firmenaccount
$sponsorRow = UserSalesVolume::where('user_id', $sponsor->id)->where('status', 5)->first();
expect($sponsorRow)->not->toBeNull()
->and((int) $sponsorRow->points)->toBe(200)
->and((int) $sponsorRow->month)->toBe(8);
$stornoRow = UserSalesVolume::where('user_id', $company->id)->where('month', 8)->where('status', 5)->first();
expect($stornoRow)->not->toBeNull()
->and((int) $stornoRow->points)->toBe(-200)
->and($stornoRow->info)->toStartWith('VP-Storno:');
// Zweiter Lauf: idempotent, nichts weiter
$second = app(SponsorBuyingPointsRepairService::class)->repair(8, 2026);
expect($second['rebooked'])->toHaveCount(0)
->and($second['created'])->toHaveCount(0);
});