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>
This commit is contained in:
parent
517d010d58
commit
d65922adfe
88 changed files with 4480 additions and 425 deletions
151
tests/Feature/AnnualFeeDueServiceTest.php
Normal file
151
tests/Feature/AnnualFeeDueServiceTest.php
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
<?php
|
||||
|
||||
use App\Models\UserAccount;
|
||||
use App\Models\UserLevel;
|
||||
use App\Services\Team\AnnualFeeDueService;
|
||||
use App\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
function afdMakeLevel(string $name): UserLevel
|
||||
{
|
||||
return UserLevel::forceCreate([
|
||||
'name' => $name,
|
||||
'pos' => 1,
|
||||
'active' => 1,
|
||||
'default' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
function afdMakeUser(?int $sponsorId, ?string $paymentAccount, ?int $levelId = null, array $accountAttributes = []): User
|
||||
{
|
||||
$account = UserAccount::create(array_merge([
|
||||
'first_name' => 'Vorname',
|
||||
'last_name' => 'Nachname',
|
||||
'phone' => '',
|
||||
'mobil' => '',
|
||||
], $accountAttributes));
|
||||
|
||||
return User::forceCreate([
|
||||
'email' => 'afd-'.uniqid('', true).'@example.com',
|
||||
'password' => bcrypt('secret'),
|
||||
'lang' => 'de',
|
||||
'account_id' => $account->id,
|
||||
'm_level' => $levelId ?? 1,
|
||||
'm_sponsor' => $sponsorId,
|
||||
'payment_account' => $paymentAccount,
|
||||
]);
|
||||
}
|
||||
|
||||
it('gruppiert Teampartner nach Jahresgebühr-Dringlichkeit mit korrekter Linie und Sortierung', function (): void {
|
||||
$reference = Carbon::parse('2026-08-15 12:00:00');
|
||||
$level = afdMakeLevel('Berater');
|
||||
|
||||
$root = afdMakeUser(null, '2027-05-01', $level->id);
|
||||
|
||||
// Linie 1
|
||||
$overdueLater = afdMakeUser($root->id, '2026-08-10', $level->id, [
|
||||
'first_name' => 'Anna', 'last_name' => 'Alt', 'phone' => '030 123', 'mobil' => '0171 111',
|
||||
]);
|
||||
$overdueEarlier = afdMakeUser($root->id, '2026-06-20', $level->id, [
|
||||
'first_name' => 'Bernd', 'last_name' => 'Blum',
|
||||
]);
|
||||
$dueOneMonth = afdMakeUser($root->id, '2026-09-10', $level->id, [
|
||||
'first_name' => 'Clara', 'last_name' => 'Cato',
|
||||
]);
|
||||
$outsideWindow = afdMakeUser($root->id, '2026-12-15', $level->id); // +4 Monate: nicht anzeigen
|
||||
$withoutPaymentAccount = afdMakeUser($root->id, null, $level->id); // ohne payment_account: weglassen
|
||||
|
||||
// Linie 2 (unter Anna)
|
||||
$dueTwoMonths = afdMakeUser($overdueLater->id, '2026-10-10', $level->id, [
|
||||
'first_name' => 'Doris', 'last_name' => 'Dahl',
|
||||
]);
|
||||
$dueThreeMonths = afdMakeUser($overdueLater->id, '2026-11-10', $level->id, [
|
||||
'first_name' => 'Emil', 'last_name' => 'Eck',
|
||||
]);
|
||||
|
||||
$groups = app(AnnualFeeDueService::class)->dueGroups($root->id, $reference);
|
||||
|
||||
// Gruppen-Reihenfolge: überfällig zuerst
|
||||
expect(array_keys($groups))->toBe([
|
||||
AnnualFeeDueService::GROUP_OVERDUE,
|
||||
AnnualFeeDueService::GROUP_DUE_1_MONTH,
|
||||
AnnualFeeDueService::GROUP_DUE_2_MONTHS,
|
||||
AnnualFeeDueService::GROUP_DUE_3_MONTHS,
|
||||
]);
|
||||
|
||||
// Überfällig: beide User, sortiert nach Fälligkeitsdatum aufsteigend (ältestes zuerst)
|
||||
$overdue = $groups[AnnualFeeDueService::GROUP_OVERDUE];
|
||||
expect($overdue)->toHaveCount(2)
|
||||
->and($overdue[0]['user_id'])->toBe($overdueEarlier->id)
|
||||
->and($overdue[1]['user_id'])->toBe($overdueLater->id)
|
||||
->and($overdue[1]['first_name'])->toBe('Anna')
|
||||
->and($overdue[1]['last_name'])->toBe('Alt')
|
||||
->and($overdue[1]['email'])->toBe($overdueLater->email)
|
||||
->and($overdue[1]['phone'])->toBe('030 123')
|
||||
->and($overdue[1]['mobil'])->toBe('0171 111')
|
||||
->and($overdue[1]['line'])->toBe(1)
|
||||
->and($overdue[1]['level_name'])->toBe('Berater')
|
||||
->and($overdue[1]['due_date']->format('Y-m-d'))->toBe('2026-08-10');
|
||||
|
||||
// ≤ 1 Monat
|
||||
$dueOne = $groups[AnnualFeeDueService::GROUP_DUE_1_MONTH];
|
||||
expect($dueOne)->toHaveCount(1)
|
||||
->and($dueOne[0]['user_id'])->toBe($dueOneMonth->id)
|
||||
->and($dueOne[0]['line'])->toBe(1);
|
||||
|
||||
// ≤ 2 Monate (Linie 2)
|
||||
$dueTwo = $groups[AnnualFeeDueService::GROUP_DUE_2_MONTHS];
|
||||
expect($dueTwo)->toHaveCount(1)
|
||||
->and($dueTwo[0]['user_id'])->toBe($dueTwoMonths->id)
|
||||
->and($dueTwo[0]['line'])->toBe(2);
|
||||
|
||||
// ≤ 3 Monate (Linie 2)
|
||||
$dueThree = $groups[AnnualFeeDueService::GROUP_DUE_3_MONTHS];
|
||||
expect($dueThree)->toHaveCount(1)
|
||||
->and($dueThree[0]['user_id'])->toBe($dueThreeMonths->id)
|
||||
->and($dueThree[0]['line'])->toBe(2);
|
||||
|
||||
// +4 Monate und ohne payment_account tauchen nirgends auf
|
||||
$allUserIds = collect($groups)->flatten(1)->pluck('user_id');
|
||||
expect($allUserIds)->not->toContain($outsideWindow->id)
|
||||
->and($allUserIds)->not->toContain($withoutPaymentAccount->id)
|
||||
->and($allUserIds)->not->toContain($root->id);
|
||||
});
|
||||
|
||||
it('liefert leere Gruppen für ein Team ohne fällige Jahresgebühren', function (): void {
|
||||
$reference = Carbon::parse('2026-08-15 12:00:00');
|
||||
$root = afdMakeUser(null, '2027-05-01');
|
||||
afdMakeUser($root->id, '2027-04-01');
|
||||
|
||||
$groups = app(AnnualFeeDueService::class)->dueGroups($root->id, $reference);
|
||||
|
||||
expect(collect($groups)->flatten(1))->toBeEmpty()
|
||||
->and(array_keys($groups))->toBe(AnnualFeeDueService::GROUP_ORDER);
|
||||
});
|
||||
|
||||
it('rendert die Jahresgebühr-Seite mit Gruppen und Hinweistext', function (): void {
|
||||
$reference = Carbon::parse('2026-08-15 12:00:00');
|
||||
$level = afdMakeLevel('Berater');
|
||||
$root = afdMakeUser(null, '2027-05-01', $level->id);
|
||||
afdMakeUser($root->id, '2026-08-10', $level->id, [
|
||||
'first_name' => 'Anna', 'last_name' => 'Alt', 'phone' => '030 123',
|
||||
]);
|
||||
|
||||
$groups = app(AnnualFeeDueService::class)->dueGroups($root->id, $reference);
|
||||
|
||||
$html = view('user.team._annual_fee_groups', [
|
||||
'groups' => $groups,
|
||||
'totalCount' => array_sum(array_map('count', $groups)),
|
||||
])->render();
|
||||
|
||||
expect($html)->toContain(__('team.annual_fee_title'))
|
||||
->toContain(__('team.annual_fee_hint'))
|
||||
->toContain(__('team.annual_fee_group_overdue'))
|
||||
->toContain('Anna')
|
||||
->toContain('Alt')
|
||||
->toContain('030 123');
|
||||
});
|
||||
|
|
@ -382,13 +382,16 @@ it('erfasst die Herkunft bei Kundenbestellungen im Shop-Checkout', function () {
|
|||
$checkoutRepository = file_get_contents(app_path('Repositories/CheckoutRepository.php'));
|
||||
$orderDetail = file_get_contents(resource_path('views/admin/sales/_detail.blade.php'));
|
||||
|
||||
expect($checkoutView)->toContain('customer_order_source');
|
||||
// Kundenentscheid F6: nur noch Freitext-Pflichtfeld, kein Dropdown mehr
|
||||
expect($checkoutView)->toContain('customer_order_source_comment');
|
||||
expect($checkoutView)->toContain('Wie bist du auf uns aufmerksam geworden?');
|
||||
expect($checkoutView)->not->toContain('name="customer_order_source"');
|
||||
expect($checkoutController)->toContain("Request::get('is_from') === 'shopping'");
|
||||
expect($checkoutController)->toContain('customer_order_source');
|
||||
expect($checkoutController)->toContain("\$rules['customer_order_source_comment'] = 'required|string|max:500'");
|
||||
expect($checkoutRepository)->toContain('$shopping_user->is_from === \'shopping\'');
|
||||
expect($checkoutRepository)->toContain('customer_order_source_comment');
|
||||
expect($orderDetail)->toContain('getCustomerOrderSourceLabel');
|
||||
expect($orderDetail)->toContain('customer_order_source_comment');
|
||||
|
||||
$shoppingOrder = (new ShoppingOrder)->forceFill([
|
||||
'customer_order_source' => 'social_media',
|
||||
|
|
|
|||
|
|
@ -223,6 +223,7 @@ it('zaehlt den Berater selbst bei den Dashboard-KPIs zum 1000-Punkte-Shop mit',
|
|||
$partner = boMakeUser($root->id);
|
||||
boMakeSalesVolume($partner->id, 6, 2026, 1300, 2);
|
||||
|
||||
app(BackofficeDashboardService::class)->flushLineBucketsMemo(); // Struktur hat sich im selben Prozess geändert
|
||||
$kpis = app(TeamAboOverviewService::class)->teamKpis($root->fresh(), 6, 2026);
|
||||
expect($kpis['shop_1000'])->toBe(2);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use App\Models\UserAboItem;
|
|||
use App\Models\UserLevel;
|
||||
use App\Models\UserSalesVolume;
|
||||
use App\Services\Backoffice\BackofficeDashboardService;
|
||||
use App\Services\Backoffice\BackofficeDrilldownService;
|
||||
use App\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
|
@ -31,7 +32,7 @@ function bslMakeLevel(string $name, int $pos): UserLevel
|
|||
]);
|
||||
}
|
||||
|
||||
function bslMakeUser(?int $sponsorId = null, ?int $levelId = null): User
|
||||
function bslMakeUser(?int $sponsorId = null, ?int $levelId = null, ?string $activeDate = null): User
|
||||
{
|
||||
return User::forceCreate([
|
||||
'email' => 'bsl-'.uniqid('', true).'@example.com',
|
||||
|
|
@ -39,7 +40,7 @@ function bslMakeUser(?int $sponsorId = null, ?int $levelId = null): User
|
|||
'lang' => 'de',
|
||||
'm_level' => $levelId ?? 1,
|
||||
'm_sponsor' => $sponsorId,
|
||||
'active_date' => '2026-01-15',
|
||||
'active_date' => $activeDate ?? '2026-01-15',
|
||||
'payment_account' => now()->addYear(),
|
||||
]);
|
||||
}
|
||||
|
|
@ -288,6 +289,8 @@ it('ergaenzt beim Snapshot-Overlay Linien, die erst nach dem Snapshot entstanden
|
|||
bslMakeAbo($ben->id, 'me', '2026-07-01');
|
||||
bslMakeAbo($ben->id, 'ot', '2026-07-02');
|
||||
|
||||
$service->flushLineBucketsMemo(); // Struktur hat sich im selben Prozess geändert
|
||||
|
||||
$overview = $service->overview($root, 5, 2026);
|
||||
|
||||
expect($overview['_meta']['source'])->toBe('snapshot')
|
||||
|
|
@ -433,3 +436,103 @@ it('ignoriert Abo-Punkte geloeschter Produkte im Soll der Top-10-Umsatzliste', f
|
|||
->and($byPoints[0]['user_id'])->toBe($anna->id)
|
||||
->and($byPoints[0]['points_target'])->toBe(40.0);
|
||||
});
|
||||
|
||||
it('rankt Recruiter des Monats nach gesponserten Neupartnern inklusive ich', function (): void {
|
||||
$root = bslMakeUser(); // active_date Januar — selbst kein Neupartner
|
||||
$anna = bslMakeUser($root->id, null, '2026-06-05');
|
||||
$ben = bslMakeUser($root->id, null, '2026-06-10');
|
||||
$carla = bslMakeUser($anna->id, null, '2026-06-12'); // von Anna im selben Monat geworben
|
||||
bslMakeUser($anna->id, null, '2026-05-12'); // Mai, zaehlt nicht
|
||||
bslMakeUser($root->id); // Januar-Bestand, zaehlt nicht
|
||||
|
||||
// abgelaufener Account: zaehlt nicht als Neupartner
|
||||
$expired = bslMakeUser($root->id, null, '2026-06-20');
|
||||
$expired->forceFill(['payment_account' => now()->subDay()])->save();
|
||||
|
||||
$ranking = app(BackofficeDashboardService::class)->overview($root, 6, 2026)['recruiter_ranking'];
|
||||
|
||||
expect($ranking)->toHaveCount(2)
|
||||
->and($ranking[0]['user_id'])->toBe($root->id)
|
||||
->and($ranking[0]['is_self'])->toBeTrue()
|
||||
->and($ranking[0]['line'])->toBe(0)
|
||||
->and($ranking[0]['new_partner_count'])->toBe(2)
|
||||
->and(collect($ranking[0]['partners'])->pluck('user_id')->sort()->values()->all())
|
||||
->toBe(collect([$anna->id, $ben->id])->sort()->values()->all())
|
||||
->and($ranking[1]['user_id'])->toBe($anna->id)
|
||||
->and($ranking[1]['is_self'])->toBeFalse()
|
||||
->and($ranking[1]['line'])->toBe(1)
|
||||
->and($ranking[1]['new_partner_count'])->toBe(1)
|
||||
->and($ranking[1]['partners'][0]['user_id'])->toBe($carla->id);
|
||||
|
||||
// Carla erscheint in Roots Detailliste eingerueckt unter Anna
|
||||
$annaEntry = collect($ranking[0]['partners'])->firstWhere('user_id', $anna->id);
|
||||
expect($annaEntry['new_partner_count'])->toBe(1)
|
||||
->and($annaEntry['children'][0]['user_id'])->toBe($carla->id);
|
||||
});
|
||||
|
||||
it('summiert das Recruiter-Volumen aus Eigen- und Kundenpunkten inklusive Kettenvolumen', function (): void {
|
||||
$root = bslMakeUser();
|
||||
$anna = bslMakeUser($root->id, null, '2026-06-05'); // Neupartner von Root
|
||||
$ben = bslMakeUser($anna->id, null, '2026-06-15'); // Neupartner von Anna (Kette)
|
||||
|
||||
// Anna: Starterpaket (Status 1, Eigenpunkte) + Kundenumsatz (Status 2)
|
||||
UserSalesVolume::forceCreate([
|
||||
'user_id' => $anna->id,
|
||||
'month' => 6,
|
||||
'year' => 2026,
|
||||
'points' => 400,
|
||||
'status' => 1,
|
||||
]);
|
||||
UserSalesVolume::forceCreate([
|
||||
'user_id' => $anna->id,
|
||||
'month' => 6,
|
||||
'year' => 2026,
|
||||
'points' => 100,
|
||||
'status' => 2,
|
||||
]);
|
||||
// Ben: nur Starterpaket
|
||||
UserSalesVolume::forceCreate([
|
||||
'user_id' => $ben->id,
|
||||
'month' => 6,
|
||||
'year' => 2026,
|
||||
'points' => 200,
|
||||
'status' => 1,
|
||||
]);
|
||||
|
||||
$ranking = collect(app(BackofficeDashboardService::class)->overview($root, 6, 2026)['recruiter_ranking'])->keyBy('user_id');
|
||||
|
||||
// Root: Annas 500 (400 eigene + 100 Kunden) + rekursiv Bens 200
|
||||
expect($ranking->get($root->id)['volume_points'])->toBe(700.0)
|
||||
->and($ranking->get($root->id)['partners'][0]['total_points'])->toBe(500.0)
|
||||
->and($ranking->get($root->id)['partners'][0]['children'][0]['total_points'])->toBe(200.0)
|
||||
// Anna selbst: nur Bens Punkte
|
||||
->and($ranking->get($anna->id)['volume_points'])->toBe(200.0);
|
||||
});
|
||||
|
||||
it('liefert im Neupartner-Drilldown zusaetzlich die Spalte Punkte gesamt', function (): void {
|
||||
$root = bslMakeUser();
|
||||
$partner = bslMakeUser($root->id, null, '2026-06-15');
|
||||
|
||||
UserSalesVolume::forceCreate([
|
||||
'user_id' => $partner->id,
|
||||
'month' => 6,
|
||||
'year' => 2026,
|
||||
'points' => 100,
|
||||
'status' => 1, // Starterpaket / Eigenpunkte
|
||||
]);
|
||||
UserSalesVolume::forceCreate([
|
||||
'user_id' => $partner->id,
|
||||
'month' => 6,
|
||||
'year' => 2026,
|
||||
'points' => 250,
|
||||
'status' => 2, // Kundenpunkte
|
||||
]);
|
||||
|
||||
$details = app(BackofficeDrilldownService::class)->details($root, 0, 'new_partners', 6, 2026);
|
||||
|
||||
expect($details['rows'])->toHaveCount(1)
|
||||
->and($details['rows'][0]['customer_points'])->toBe(250.0)
|
||||
->and($details['rows'][0]['total_points'])->toBe(350.0)
|
||||
->and($details['summary']['customer_points'])->toBe(250.0)
|
||||
->and($details['summary']['total_points'])->toBe(350.0);
|
||||
});
|
||||
|
|
|
|||
72
tests/Feature/CheckoutOrderSourceValidationTest.php
Normal file
72
tests/Feature/CheckoutOrderSourceValidationTest.php
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\Web\CheckoutController;
|
||||
use Illuminate\Validation\Validator;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
/**
|
||||
* Ruft die private Checkout-Validierung mit dem gegebenen Request-Input auf.
|
||||
* (route() ist wegen Domain-Routing im Test nicht verfügbar, daher direkt.)
|
||||
*/
|
||||
function cosvValidateCheckout(array $input): Validator
|
||||
{
|
||||
request()->replace($input);
|
||||
|
||||
$method = new ReflectionMethod(CheckoutController::class, 'validateCheckoutData');
|
||||
|
||||
return $method->invoke(app(CheckoutController::class));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
function cosvBaseCheckoutInput(array $overrides = []): array
|
||||
{
|
||||
return array_merge([
|
||||
'is_from' => 'shopping',
|
||||
'billing_salutation' => 'Frau',
|
||||
'billing_firstname' => 'Erika',
|
||||
'billing_lastname' => 'Muster',
|
||||
'billing_email' => 'erika@example.com',
|
||||
'billing_address' => 'Musterweg 1',
|
||||
'billing_zipcode' => '12345',
|
||||
'billing_city' => 'Berlin',
|
||||
'accepted_data_checkbox' => '1',
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
it('verlangt den Herkunfts-Freitext bei Shop-Kundenbestellungen', function () {
|
||||
$validator = cosvValidateCheckout(cosvBaseCheckoutInput());
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
expect($validator->errors()->has('customer_order_source_comment'))->toBeTrue();
|
||||
expect($validator->errors()->first('customer_order_source_comment'))->toContain('aufmerksam geworden');
|
||||
});
|
||||
|
||||
it('akzeptiert den Checkout ohne das frühere Herkunfts-Dropdown', function () {
|
||||
$validator = cosvValidateCheckout(cosvBaseCheckoutInput([
|
||||
'customer_order_source_comment' => 'Empfehlung von Maria Mustermann',
|
||||
]));
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
expect($validator->errors()->has('customer_order_source'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('begrenzt den Herkunfts-Freitext auf 500 Zeichen', function () {
|
||||
$validator = cosvValidateCheckout(cosvBaseCheckoutInput([
|
||||
'customer_order_source_comment' => str_repeat('a', 501),
|
||||
]));
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
expect($validator->errors()->has('customer_order_source_comment'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('verlangt keinen Herkunfts-Freitext außerhalb des Shop-Checkouts', function () {
|
||||
$validator = cosvValidateCheckout(cosvBaseCheckoutInput([
|
||||
'is_from' => 'user',
|
||||
]));
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
});
|
||||
|
|
@ -11,6 +11,7 @@ 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\Incentive\IncentivePointsLogRepairService;
|
||||
use App\Services\Incentive\IncentiveTracker;
|
||||
|
|
@ -188,6 +189,25 @@ it('trackNewPartner zaehlt Bestellungen am letzten Tag des Qualifikationszeitrau
|
|||
expect(IncentiveNewPartner::where('user_id', $partner->id)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('syncMissingTrackingPartners zaehlt Bestellungen am letzten Tag des Qualifikationszeitraums (Randtag-Fall)', function () {
|
||||
Carbon::setTestNow(Carbon::parse('2026-08-01 04:00:00'));
|
||||
|
||||
$incentive = repairTestCreateIncentive();
|
||||
$sponsor = repairTestCreateUser();
|
||||
$participant = IncentiveParticipant::factory()->create([
|
||||
'incentive_id' => $incentive->id,
|
||||
'user_id' => $sponsor->id,
|
||||
]);
|
||||
|
||||
$partner = repairTestCreateUser($sponsor->id);
|
||||
repairTestCreateStarterKitOrder($partner, $sponsor, '2026-07-31 23:15:00');
|
||||
|
||||
$added = app(IncentivePointsLogRepairService::class)->syncMissingTrackingPartners($participant);
|
||||
|
||||
expect($added)->toBe(1)
|
||||
->and(IncentiveNewPartner::where('participant_id', $participant->id)->where('user_id', $partner->id)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('removeStaleSponsorTrackings verschiebt Partner nach Sponsorwechsel zum aktuellen Sponsor', function () {
|
||||
$incentive = repairTestCreateIncentive();
|
||||
|
||||
|
|
@ -246,6 +266,145 @@ it('removeStaleSponsorTrackings verschiebt Partner nach Sponsorwechsel zum aktue
|
|||
expect((int) $participantNew->fresh()->qualified_partners)->toBe(1);
|
||||
});
|
||||
|
||||
it('removeStaleSponsorTrackings behaelt Tracking und Punkte-Logs bei leerem Sponsor (m_sponsor = NULL)', function () {
|
||||
$incentive = repairTestCreateIncentive();
|
||||
$sponsor = repairTestCreateUser();
|
||||
$participant = IncentiveParticipant::factory()->create([
|
||||
'incentive_id' => $incentive->id,
|
||||
'user_id' => $sponsor->id,
|
||||
]);
|
||||
|
||||
$partner = repairTestCreateUser($sponsor->id);
|
||||
$tracking = IncentiveNewPartner::create([
|
||||
'participant_id' => $participant->id,
|
||||
'user_id' => $partner->id,
|
||||
'registered_at' => Carbon::parse('2026-05-10 09:00:00'),
|
||||
]);
|
||||
IncentivePointsLog::create([
|
||||
'participant_id' => $participant->id,
|
||||
'type' => 'partner',
|
||||
'source_type' => User::class,
|
||||
'source_id' => $partner->id,
|
||||
'source_label' => 'Partner',
|
||||
'month' => 5,
|
||||
'year' => 2026,
|
||||
'points_onetime' => 600,
|
||||
'points_accumulated' => 0,
|
||||
'incentive_new_partner_id' => $tracking->id,
|
||||
]);
|
||||
|
||||
// Admin-Umbau in Arbeit: Sponsor temporaer entfernt — kein Sponsorwechsel
|
||||
$partner->m_sponsor = null;
|
||||
$partner->save();
|
||||
|
||||
$removed = app(IncentivePointsLogRepairService::class)->removeStaleSponsorTrackings($participant);
|
||||
|
||||
expect($removed)->toBe(0)
|
||||
->and(IncentiveNewPartner::where('participant_id', $participant->id)->where('user_id', $partner->id)->exists())->toBeTrue()
|
||||
->and(IncentivePointsLog::where('participant_id', $participant->id)->count())->toBe(1);
|
||||
|
||||
$participant->recalculateFromTrackingTables()->save();
|
||||
expect((int) $participant->fresh()->total_points)->toBe(600);
|
||||
});
|
||||
|
||||
it('removeStaleSponsorTrackings behaelt Trackings geloeschter User (wie bisher)', function () {
|
||||
$incentive = repairTestCreateIncentive();
|
||||
$sponsor = repairTestCreateUser();
|
||||
$participant = IncentiveParticipant::factory()->create([
|
||||
'incentive_id' => $incentive->id,
|
||||
'user_id' => $sponsor->id,
|
||||
]);
|
||||
|
||||
$partner = repairTestCreateUser($sponsor->id);
|
||||
IncentiveNewPartner::create([
|
||||
'participant_id' => $participant->id,
|
||||
'user_id' => $partner->id,
|
||||
'registered_at' => now(),
|
||||
]);
|
||||
|
||||
$partner->delete();
|
||||
|
||||
$removed = app(IncentivePointsLogRepairService::class)->removeStaleSponsorTrackings($participant);
|
||||
|
||||
expect($removed)->toBe(0)
|
||||
->and(IncentiveNewPartner::where('participant_id', $participant->id)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('incentive:calculate repariert FKs vor der Stale-Bereinigung — keine Waisen-Logs beim alten Sponsor nach Umhaengen', function () {
|
||||
$incentive = repairTestCreateIncentive();
|
||||
|
||||
$oldSponsor = repairTestCreateUser();
|
||||
$newSponsor = repairTestCreateUser();
|
||||
|
||||
$participantOld = IncentiveParticipant::factory()->create([
|
||||
'incentive_id' => $incentive->id,
|
||||
'user_id' => $oldSponsor->id,
|
||||
]);
|
||||
$participantNew = IncentiveParticipant::factory()->create([
|
||||
'incentive_id' => $incentive->id,
|
||||
'user_id' => $newSponsor->id,
|
||||
]);
|
||||
|
||||
// Partner wurde unter dem alten Sponsor getrackt, haengt inzwischen unter dem neuen
|
||||
$partner = repairTestCreateUser($newSponsor->id);
|
||||
repairTestCreateStarterKitOrder($partner, $newSponsor, '2026-05-10 09:00:00');
|
||||
|
||||
$staleTracking = IncentiveNewPartner::create([
|
||||
'participant_id' => $participantOld->id,
|
||||
'user_id' => $partner->id,
|
||||
'registered_at' => Carbon::parse('2026-05-10 09:00:00'),
|
||||
]);
|
||||
IncentivePointsLog::create([
|
||||
'participant_id' => $participantOld->id,
|
||||
'type' => 'partner',
|
||||
'source_type' => User::class,
|
||||
'source_id' => $partner->id,
|
||||
'source_label' => 'Partner',
|
||||
'month' => 5,
|
||||
'year' => 2026,
|
||||
'points_onetime' => 600,
|
||||
'points_accumulated' => 0,
|
||||
'incentive_new_partner_id' => $staleTracking->id,
|
||||
]);
|
||||
|
||||
// Umsatz-Log OHNE FK (Altbestand): ueberlebte frueher die Stale-Bereinigung als Waise
|
||||
$sv = UserSalesVolume::create([
|
||||
'user_id' => $partner->id,
|
||||
'month' => 5,
|
||||
'year' => 2026,
|
||||
'date' => '12.05.2026',
|
||||
'points' => 25,
|
||||
'total_net' => 90,
|
||||
'status_points' => 1,
|
||||
'status' => 2,
|
||||
'message' => 'SV 5/2026',
|
||||
]);
|
||||
IncentivePointsLog::create([
|
||||
'participant_id' => $participantOld->id,
|
||||
'type' => 'partner',
|
||||
'source_type' => UserSalesVolume::class,
|
||||
'source_id' => $sv->id,
|
||||
'source_label' => 'SV 5/2026',
|
||||
'month' => 5,
|
||||
'year' => 2026,
|
||||
'points_onetime' => 0,
|
||||
'points_accumulated' => 25,
|
||||
'user_sales_volume_id' => $sv->id,
|
||||
'incentive_new_partner_id' => null,
|
||||
]);
|
||||
|
||||
$this->artisan('incentive:calculate', ['incentive_id' => $incentive->id])->assertExitCode(0);
|
||||
|
||||
// Alter Sponsor: Tracking + ALLE Logs weg (auch der Waisen-Log ohne FK), Punkte 0
|
||||
expect(IncentiveNewPartner::where('participant_id', $participantOld->id)->exists())->toBeFalse()
|
||||
->and(IncentivePointsLog::where('participant_id', $participantOld->id)->count())->toBe(0)
|
||||
->and((int) $participantOld->fresh()->total_points)->toBe(0);
|
||||
|
||||
// Neuer Sponsor: Partner nachgezogen inkl. Einmal- und Umsatzpunkten (keine Doppelzaehlung)
|
||||
expect((int) $participantNew->fresh()->qualified_partners)->toBe(1)
|
||||
->and((int) $participantNew->fresh()->total_points)->toBe(625);
|
||||
});
|
||||
|
||||
it('removeStaleSponsorTrackings laesst korrekte Trackings unangetastet', function () {
|
||||
$incentive = repairTestCreateIncentive();
|
||||
$sponsor = repairTestCreateUser();
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ it('berechnet den Live-Snapshot ueber den gemeinsamen Lauf und cached ihn', func
|
|||
->and($snapshot['failed'])->toBeFalse()
|
||||
->and($snapshot['payline_points'])->toBe(0.0)
|
||||
->and($snapshot['paylines'])->toBe(0)
|
||||
->and(Cache::has("live_business_{$user->id}_7_2026"))->toBeTrue();
|
||||
->and(Cache::has("live_business_v3_{$user->id}_7_2026"))->toBeTrue();
|
||||
});
|
||||
|
||||
it('faellt bei fehlschlagender Live-Berechnung auf Null-Werte zurueck und cached den Fehler nicht', function (): void {
|
||||
|
|
@ -75,14 +75,14 @@ it('faellt bei fehlschlagender Live-Berechnung auf Null-Werte zurueck und cached
|
|||
|
||||
expect($commission['total'])->toBe(0.0)
|
||||
->and($commission['is_live'])->toBeTrue()
|
||||
->and(Cache::has("live_business_{$user->id}_7_2026"))->toBeFalse();
|
||||
->and(Cache::has("live_business_v3_{$user->id}_7_2026"))->toBeFalse();
|
||||
});
|
||||
|
||||
it('cached die Live-Provision fuer Folgeaufrufe', function (): void {
|
||||
$user = lcsMakeUser();
|
||||
|
||||
Cache::put(
|
||||
"live_business_{$user->id}_7_2026",
|
||||
"live_business_v3_{$user->id}_7_2026",
|
||||
['payline_points' => 12.5, 'paylines' => 2, 'pp' => 99.0, 'shop' => 0.0, 'growth' => 0.0, 'is_live' => true, 'failed' => false],
|
||||
now()->addHour()
|
||||
);
|
||||
|
|
@ -149,3 +149,35 @@ it('rendert die Provisions-Kachel auf der Monatsstatistik-Card', function (): vo
|
|||
->toContain(__('home.commission_today'))
|
||||
->toContain('160,75 €');
|
||||
});
|
||||
|
||||
it('reicht Level- und Quali-Daten im Snapshot durch (N1/N4)', 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$user = lcsMakeUser();
|
||||
|
||||
UserBusiness::forceCreate([
|
||||
'user_id' => $user->id,
|
||||
'month' => 6,
|
||||
'year' => 2026,
|
||||
'version' => 2,
|
||||
'payline_points' => 900.0,
|
||||
'payline_points_qual_kp' => 950.0,
|
||||
'sales_volume_points_KP_sum' => 300.0,
|
||||
'qual_user_level' => ['name' => 'Gold', 'paylines' => 3],
|
||||
'qual_user_level_next' => ['name' => 'Diamant', 'qual_pp' => 2000, 'qual_kp' => 600],
|
||||
]);
|
||||
|
||||
$snapshot = app(LiveCommissionService::class)->snapshot($user, 6, 2026);
|
||||
|
||||
expect($snapshot['qual_level']['name'])->toBe('Gold')
|
||||
->and($snapshot['next_level']['name'])->toBe('Diamant')
|
||||
->and($snapshot['payline_points_qual_kp'])->toBe(950.0)
|
||||
->and($snapshot['personal_points'])->toBe(300.0)
|
||||
->and($snapshot['paylines'])->toBe(3);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -252,3 +252,78 @@ it('rendert die Payline-Prognose auf der Monatsstatistik-Card', function (): voi
|
|||
->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);
|
||||
});
|
||||
|
|
|
|||
79
tests/Feature/ProfileImageTest.php
Normal file
79
tests/Feature/ProfileImageTest.php
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\ProfileImageController;
|
||||
use App\Models\UserAccount;
|
||||
use App\Services\Slim;
|
||||
use App\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
function piMakeUser(): User
|
||||
{
|
||||
$account = UserAccount::forceCreate([
|
||||
'first_name' => 'Pia',
|
||||
'last_name' => 'Probe',
|
||||
]);
|
||||
|
||||
return User::forceCreate([
|
||||
'email' => 'pi-'.uniqid('', true).'@example.com',
|
||||
'password' => bcrypt('secret'),
|
||||
'lang' => 'de',
|
||||
'm_level' => 1,
|
||||
'account_id' => $account->id,
|
||||
'payment_account' => now()->addYear(),
|
||||
]);
|
||||
}
|
||||
|
||||
it('verweigert den Foto-Upload ohne Einwilligung', function (): void {
|
||||
URL::resolveMissingNamedRoutesUsing(fn (string $name, $parameters = []) => '/'.$name);
|
||||
Storage::fake('public');
|
||||
|
||||
$user = piMakeUser();
|
||||
$this->actingAs($user);
|
||||
Request::merge(['profile_image_consent' => null]);
|
||||
|
||||
app(ProfileImageController::class)->uploadImage();
|
||||
|
||||
expect(session('alert-danger'))->toBe(__('account.profile_image_consent_required'))
|
||||
->and($user->account->fresh()->profile_image)->toBeNull();
|
||||
});
|
||||
|
||||
it('speichert ein Slim-Upload nur fuer jpg/png und liefert die Metadaten', function (): void {
|
||||
Storage::fake('public');
|
||||
|
||||
$image = [
|
||||
'input' => ['size' => 1234],
|
||||
'output' => ['data' => 'BINARYDATA', 'name' => 'Mein Foto!.jpg', 'type' => 'image/jpeg'],
|
||||
];
|
||||
|
||||
$stored = Slim::storeCroppedImage($image, 'images/profile');
|
||||
|
||||
expect($stored)->not->toBeNull()
|
||||
->and($stored['ext'])->toBe('jpg')
|
||||
->and($stored['mime'])->toBe('image/jpeg')
|
||||
->and($stored['size'])->toBe(1234);
|
||||
Storage::disk('public')->assertExists('images/profile/'.$stored['filename']);
|
||||
|
||||
$gif = ['input' => [], 'output' => ['data' => 'X', 'name' => 'a.gif', 'type' => 'image/gif']];
|
||||
expect(Slim::storeCroppedImage($gif, 'images/profile'))->toBeNull();
|
||||
});
|
||||
|
||||
it('loescht das Profilfoto samt Datei', function (): void {
|
||||
URL::resolveMissingNamedRoutesUsing(fn (string $name, $parameters = []) => '/'.$name);
|
||||
Storage::fake('public');
|
||||
|
||||
$user = piMakeUser();
|
||||
Storage::disk('public')->put('images/profile/test_pic.jpg', 'BINARY');
|
||||
$user->account->forceFill(['profile_image' => 'test_pic.jpg'])->save();
|
||||
|
||||
$this->actingAs($user);
|
||||
app(ProfileImageController::class)->deleteImage();
|
||||
|
||||
expect($user->account->fresh()->profile_image)->toBeNull();
|
||||
Storage::disk('public')->assertMissing('images/profile/test_pic.jpg');
|
||||
});
|
||||
433
tests/Feature/SalesVolume/SponsorBuyingPointsRepairTest.php
Normal file
433
tests/Feature/SalesVolume/SponsorBuyingPointsRepairTest.php
Normal file
|
|
@ -0,0 +1,433 @@
|
|||
<?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);
|
||||
});
|
||||
|
|
@ -5,6 +5,7 @@ use App\Models\ShoppingOrder;
|
|||
use App\Models\UserAbo;
|
||||
use App\Models\UserAboItem;
|
||||
use App\Models\UserAboOrder;
|
||||
use App\Models\UserAboStatusHistory;
|
||||
use App\Models\UserSalesVolume;
|
||||
use App\Services\Abo\TeamAboOverviewService;
|
||||
use App\Services\HTMLHelper;
|
||||
|
|
@ -196,6 +197,61 @@ it('liefert je Ebene abgerechnete Abo-Punkte und die Prognose bis zum letzten Li
|
|||
Carbon\Carbon::setTestNow();
|
||||
});
|
||||
|
||||
it('liefert Abo-Bestaende monatsbezogen: vergangene Monate zeigen den Bestand zum Monatsende', function (): void {
|
||||
Carbon\Carbon::setTestNow('2026-08-15');
|
||||
|
||||
$root = makeUser();
|
||||
$anna = makeUser($root->id);
|
||||
|
||||
// Läuft seit Mai bis heute: zählt in Mai, Juli und im Live-Monat August
|
||||
makeAbo($anna->id, $anna->id, 'me', '2026-05-10');
|
||||
// Im Juli gekündigt: zählt im Juni, nicht mehr im Juli-Bestand (Stichtag 31.07.)
|
||||
makeAbo($anna->id, $anna->id, 'ot', '2026-06-20')
|
||||
->update(['status' => 4, 'active' => false, 'cancel_date' => '2026-07-15']);
|
||||
// Im Juli soft-geloescht: zählt im Juni, nicht mehr im Juli
|
||||
$deleted = makeAbo($anna->id, $anna->id, 'ot', '2026-05-01');
|
||||
$deleted->delete();
|
||||
UserAbo::withTrashed()->whereKey($deleted->id)->update(['deleted_at' => '2026-07-10']);
|
||||
// Heute inaktiv, aber laut Status-Historie erst im August deaktiviert: zählt im Juli noch
|
||||
$paused = makeAbo($anna->id, $anna->id, 'ot', '2026-06-01');
|
||||
$paused->update(['active' => false]);
|
||||
UserAboStatusHistory::forceCreate([
|
||||
'user_abo_id' => $paused->id,
|
||||
'action' => UserAboStatusHistory::ACTION_DEACTIVATED,
|
||||
'created_at' => '2026-08-02',
|
||||
]);
|
||||
// Laut Historie schon im Juni deaktiviert, erst im August geloescht: darf
|
||||
// im Juni/Juli NICHT wiederauferstehen (spaeteres Ereignis heilt nichts)
|
||||
$deactivatedEarly = makeAbo($anna->id, $anna->id, 'ot', '2026-05-20');
|
||||
$deactivatedEarly->update(['active' => false]);
|
||||
UserAboStatusHistory::forceCreate([
|
||||
'user_abo_id' => $deactivatedEarly->id,
|
||||
'action' => UserAboStatusHistory::ACTION_DEACTIVATED,
|
||||
'created_at' => '2026-06-10',
|
||||
]);
|
||||
$deactivatedEarly->delete();
|
||||
UserAbo::withTrashed()->whereKey($deactivatedEarly->id)->update(['deleted_at' => '2026-08-05']);
|
||||
|
||||
$svc = app(TeamAboOverviewService::class);
|
||||
|
||||
$august = $svc->teamKpis($root, 8, 2026);
|
||||
expect($august['team_partner_abos'])->toBe(1)
|
||||
->and($august['team_customer_abos'])->toBe(0); // live: gekündigt/gelöscht/deaktiviert zählen nicht
|
||||
|
||||
$juli = $svc->teamKpis($root, 7, 2026);
|
||||
expect($juli['team_partner_abos'])->toBe(1)
|
||||
->and($juli['team_customer_abos'])->toBe(1); // nur das erst im August deaktivierte
|
||||
|
||||
$juni = $svc->teamKpis($root, 6, 2026);
|
||||
expect($juni['team_customer_abos'])->toBe(3); // gekündigtes + gelöschtes + später deaktiviertes
|
||||
|
||||
$mai = $svc->teamKpis($root, 5, 2026);
|
||||
expect($mai['team_partner_abos'])->toBe(1)
|
||||
->and($mai['team_customer_abos'])->toBe(2); // Mai-Kundenabo (gelöscht 07/26) + das erst im Juni deaktivierte
|
||||
|
||||
Carbon\Carbon::setTestNow();
|
||||
});
|
||||
|
||||
it('liefert pro Mitglied das Eigen-Abo-Flag und Punkte', function (): void {
|
||||
$root = makeUser();
|
||||
$anna = makeUser($root->id);
|
||||
|
|
|
|||
81
tests/Feature/TeamFirstlinesPageTest.php
Normal file
81
tests/Feature/TeamFirstlinesPageTest.php
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\User\TeamController;
|
||||
use App\Models\UserAccount;
|
||||
use App\Models\UserSalesVolume;
|
||||
use App\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Request;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
function tflMakeUser(?int $sponsorId = null, array $accountData = []): User
|
||||
{
|
||||
$account = UserAccount::forceCreate(array_merge([
|
||||
'first_name' => 'Vor'.uniqid(),
|
||||
'last_name' => 'Nach'.uniqid(),
|
||||
'phone' => '0123456789',
|
||||
], $accountData));
|
||||
|
||||
return User::forceCreate([
|
||||
'email' => 'tfl-'.uniqid('', true).'@example.com',
|
||||
'password' => bcrypt('secret'),
|
||||
'lang' => 'de',
|
||||
'm_level' => 1,
|
||||
'm_sponsor' => $sponsorId,
|
||||
'account_id' => $account->id,
|
||||
'active_date' => '2026-01-15',
|
||||
'payment_account' => now()->addYear(),
|
||||
]);
|
||||
}
|
||||
|
||||
it('listet die Firstlines mit Monats-Gesamtpunkten sortiert und laesst Tiefere aus', function (): void {
|
||||
URL::resolveMissingNamedRoutesUsing(fn (string $name, $parameters = []) => '/'.$name);
|
||||
|
||||
$root = tflMakeUser();
|
||||
$anna = tflMakeUser($root->id, ['first_name' => 'Anna', 'last_name' => 'Alpha']);
|
||||
$ben = tflMakeUser($root->id, ['first_name' => 'Ben', 'last_name' => 'Beta']);
|
||||
tflMakeUser($anna->id); // Ebene 2: keine Firstline
|
||||
|
||||
// Eigene (Status 1) + Kundenpunkte (Status 2) zaehlen zusammen
|
||||
UserSalesVolume::forceCreate(['user_id' => $anna->id, 'month' => 6, 'year' => 2026, 'points' => 100, 'status' => 1]);
|
||||
UserSalesVolume::forceCreate(['user_id' => $anna->id, 'month' => 6, 'year' => 2026, 'points' => 50, 'status' => 2]);
|
||||
UserSalesVolume::forceCreate(['user_id' => $ben->id, 'month' => 6, 'year' => 2026, 'points' => 400, 'status' => 2]);
|
||||
UserSalesVolume::forceCreate(['user_id' => $ben->id, 'month' => 5, 'year' => 2026, 'points' => 900, 'status' => 2]); // anderer Monat
|
||||
|
||||
$this->actingAs($root);
|
||||
Request::merge(['month' => 6, 'year' => 2026]);
|
||||
Request::instance()->setLaravelSession(app('session')->driver());
|
||||
|
||||
$view = app(TeamController::class)->firstlines();
|
||||
$rows = $view->getData()['rows'];
|
||||
|
||||
expect($rows)->toHaveCount(2)
|
||||
->and($rows[0]['user_id'])->toBe($ben->id) // 400 Punkte zuerst
|
||||
->and($rows[0]['total_points'])->toBe(400.0)
|
||||
->and($rows[1]['user_id'])->toBe($anna->id)
|
||||
->and($rows[1]['total_points'])->toBe(150.0)
|
||||
->and($rows[1]['name'])->toBe('Anna Alpha')
|
||||
->and($rows[1]['phone'])->toBe('0123456789')
|
||||
->and($rows[1]['email'])->toBe($anna->email);
|
||||
|
||||
// View rendert ohne Fehler (Route-Fallback aktiv)
|
||||
expect($view->render())
|
||||
->toContain('Anna Alpha')
|
||||
->toContain('Ben Beta');
|
||||
});
|
||||
|
||||
it('zeigt eine leere Firstlines-Liste ohne direkte Teampartner', function (): void {
|
||||
URL::resolveMissingNamedRoutesUsing(fn (string $name, $parameters = []) => '/'.$name);
|
||||
|
||||
$root = tflMakeUser();
|
||||
$this->actingAs($root);
|
||||
Request::instance()->setLaravelSession(app('session')->driver());
|
||||
|
||||
$view = app(TeamController::class)->firstlines();
|
||||
|
||||
expect($view->getData()['rows'])->toBe([])
|
||||
->and($view->render())->toContain(e(__('team.firstlines_none')));
|
||||
});
|
||||
219
tests/Feature/TopResultsServiceTest.php
Normal file
219
tests/Feature/TopResultsServiceTest.php
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
<?php
|
||||
|
||||
use App\Models\UserAbo;
|
||||
use App\Models\UserAccount;
|
||||
use App\Models\UserSalesVolume;
|
||||
use App\Services\Team\TopResultsService;
|
||||
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-08-15'));
|
||||
Cache::flush();
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
function trsMakeUser(
|
||||
?int $sponsorId = null,
|
||||
string $firstName = 'Team',
|
||||
string $lastName = 'Partner',
|
||||
?string $activeDate = null,
|
||||
?string $paymentAccount = 'future',
|
||||
): User {
|
||||
$account = UserAccount::forceCreate([
|
||||
'first_name' => $firstName,
|
||||
'last_name' => $lastName,
|
||||
]);
|
||||
|
||||
return User::forceCreate([
|
||||
'email' => 'trs-'.uniqid('', true).'@example.com',
|
||||
'password' => bcrypt('secret'),
|
||||
'lang' => 'de',
|
||||
'm_level' => 1,
|
||||
'm_sponsor' => $sponsorId,
|
||||
'payment_account' => $paymentAccount === 'future' ? now()->addYear() : $paymentAccount,
|
||||
'active_date' => $activeDate,
|
||||
'account_id' => $account->id,
|
||||
]);
|
||||
}
|
||||
|
||||
function trsAddVolume(int $userId, int $points, int $status, int $month = 8, int $year = 2026): void
|
||||
{
|
||||
UserSalesVolume::forceCreate([
|
||||
'user_id' => $userId,
|
||||
'month' => $month,
|
||||
'year' => $year,
|
||||
'points' => $points,
|
||||
'status' => $status,
|
||||
]);
|
||||
}
|
||||
|
||||
function trsMakeAbo(int $ownerId, string $isFor, string $startDate, bool $active = true, int $status = 2): UserAbo
|
||||
{
|
||||
return UserAbo::create([
|
||||
'user_id' => $ownerId,
|
||||
'member_id' => $ownerId,
|
||||
'shopping_user_id' => 0,
|
||||
'email' => 'trs-abo-'.uniqid('', true).'@example.com',
|
||||
'is_for' => $isFor,
|
||||
'payone_userid' => 0,
|
||||
'clearingtype' => 'cc',
|
||||
'amount' => 5000,
|
||||
'active' => $active,
|
||||
'status' => $status,
|
||||
'abo_interval' => 5,
|
||||
'start_date' => $startDate,
|
||||
'next_date' => '2026-09-05',
|
||||
]);
|
||||
}
|
||||
|
||||
function trsTop3(User $viewer): array
|
||||
{
|
||||
Cache::flush();
|
||||
|
||||
return app(TopResultsService::class)->monthlyTop3($viewer, 8, 2026);
|
||||
}
|
||||
|
||||
it('rankt das persoenliche Volumen inkl. Status-1- und Status-2-Punkten, ohne Betrachter und Fremde', function (): void {
|
||||
$root = trsMakeUser(null, 'Root', 'Viewer');
|
||||
$anna = trsMakeUser($root->id, 'Anna', 'Alpha');
|
||||
$ben = trsMakeUser($root->id, 'Ben', 'Beta');
|
||||
$fremd = trsMakeUser(null, 'Frank', 'Fremd');
|
||||
|
||||
trsAddVolume($anna->id, 100, 1); // Eigenpunkte
|
||||
trsAddVolume($anna->id, 50, 2); // Kundenpunkte
|
||||
trsAddVolume($anna->id, 999, 3); // pending zaehlt nicht
|
||||
trsAddVolume($ben->id, 40, 1);
|
||||
trsAddVolume($ben->id, 70, 1, 7); // anderer Monat zaehlt nicht
|
||||
trsAddVolume($root->id, 500, 1); // Betrachter zaehlt nicht mit
|
||||
trsAddVolume($fremd->id, 800, 1); // nicht im Team
|
||||
|
||||
$result = trsTop3($root);
|
||||
|
||||
expect($result['top_volume'])->toHaveCount(2)
|
||||
->and($result['top_volume'][0])->toMatchArray(['user_id' => $anna->id, 'first_name' => 'Anna', 'last_name' => 'Alpha', 'value' => 150.0])
|
||||
->and($result['top_volume'][0]['profile_image_url'])->toBeNull()
|
||||
->and($result['top_volume'][1])->toMatchArray(['user_id' => $ben->id, 'value' => 40.0]);
|
||||
});
|
||||
|
||||
it('begrenzt auf Top 3 und sortiert Gleichstaende nach Name', function (): void {
|
||||
$root = trsMakeUser(null, 'Root', 'Viewer');
|
||||
$carla = trsMakeUser($root->id, 'Carla', 'Chef');
|
||||
$zoe = trsMakeUser($root->id, 'Zoe', 'Zett');
|
||||
$anna = trsMakeUser($root->id, 'Anna', 'Alpha');
|
||||
$doris = trsMakeUser($root->id, 'Doris', 'Klein');
|
||||
|
||||
trsAddVolume($carla->id, 200, 1);
|
||||
trsAddVolume($zoe->id, 100, 1);
|
||||
trsAddVolume($anna->id, 100, 1);
|
||||
trsAddVolume($doris->id, 50, 1); // Platz 4, faellt raus
|
||||
|
||||
$result = trsTop3($root);
|
||||
|
||||
expect($result['top_volume'])->toHaveCount(3)
|
||||
->and(array_column($result['top_volume'], 'user_id'))
|
||||
->toBe([$carla->id, $anna->id, $zoe->id]);
|
||||
});
|
||||
|
||||
it('zaehlt Neupartner je Sponsor nach der Dashboard-Definition', function (): void {
|
||||
$root = trsMakeUser(null, 'Root', 'Viewer');
|
||||
$sponsor = trsMakeUser($root->id, 'Sandra', 'Sponsor');
|
||||
$other = trsMakeUser($root->id, 'Olaf', 'Other');
|
||||
|
||||
trsMakeUser($sponsor->id, 'Neu', 'Eins', '2026-08-10');
|
||||
trsMakeUser($sponsor->id, 'Neu', 'Zwei', '2026-08-20');
|
||||
trsMakeUser($sponsor->id, 'Alt', 'Juli', '2026-07-10'); // anderer Monat
|
||||
trsMakeUser($sponsor->id, 'Neu', 'Inaktiv', '2026-08-12', null); // kein aktives Abrechnungskonto
|
||||
trsMakeUser($other->id, 'Neu', 'Drei', '2026-08-05');
|
||||
trsMakeUser($root->id, 'Neu', 'BeimViewer', '2026-08-08'); // Sponsor = Betrachter, zaehlt nicht
|
||||
|
||||
$result = trsTop3($root);
|
||||
|
||||
expect($result['top_recruiters'])->toHaveCount(2)
|
||||
->and($result['top_recruiters'][0])->toMatchArray(['user_id' => $sponsor->id, 'value' => 2])
|
||||
->and($result['top_recruiters'][1])->toMatchArray(['user_id' => $other->id, 'value' => 1]);
|
||||
});
|
||||
|
||||
it('zaehlt neue Abos als Kundenabos plus Eigenabo je Berater', function (): void {
|
||||
$root = trsMakeUser(null, 'Root', 'Viewer');
|
||||
$anna = trsMakeUser($root->id, 'Anna', 'Alpha');
|
||||
$ben = trsMakeUser($root->id, 'Ben', 'Beta');
|
||||
$fremd = trsMakeUser(null, 'Frank', 'Fremd');
|
||||
|
||||
trsMakeAbo($anna->id, 'ot', '2026-08-05');
|
||||
trsMakeAbo($anna->id, 'ot', '2026-08-20');
|
||||
trsMakeAbo($anna->id, 'me', '2026-08-03');
|
||||
trsMakeAbo($anna->id, 'ot', '2026-07-05'); // anderer Monat
|
||||
trsMakeAbo($anna->id, 'me', '2026-08-10', true, 4); // gekuendigt
|
||||
trsMakeAbo($anna->id, 'ot', '2026-08-11', false); // inaktiv
|
||||
trsMakeAbo($ben->id, 'me', '2026-08-15');
|
||||
trsMakeAbo($root->id, 'ot', '2026-08-09'); // Betrachter zaehlt nicht mit
|
||||
trsMakeAbo($fremd->id, 'ot', '2026-08-09'); // nicht im Team
|
||||
|
||||
$result = trsTop3($root);
|
||||
|
||||
expect($result['top_new_abos'])->toHaveCount(2)
|
||||
->and($result['top_new_abos'][0])->toMatchArray(['user_id' => $anna->id, 'value' => 3])
|
||||
->and($result['top_new_abos'][1])->toMatchArray(['user_id' => $ben->id, 'value' => 1]);
|
||||
});
|
||||
|
||||
it('cacht das Ergebnis fuer 6 Stunden', function (): void {
|
||||
$root = trsMakeUser(null, 'Root', 'Viewer');
|
||||
$anna = trsMakeUser($root->id, 'Anna', 'Alpha');
|
||||
|
||||
trsAddVolume($anna->id, 100, 1);
|
||||
$first = app(TopResultsService::class)->monthlyTop3($root, 8, 2026);
|
||||
|
||||
trsAddVolume($anna->id, 900, 1);
|
||||
$second = app(TopResultsService::class)->monthlyTop3($root, 8, 2026);
|
||||
|
||||
expect($first['top_volume'][0]['value'])->toBe(100.0)
|
||||
->and($second)->toBe($first);
|
||||
});
|
||||
|
||||
it('rendert die Top-Ergebnisse-Karte nur mit Eintraegen', function (): void {
|
||||
URL::resolveMissingNamedRoutesUsing(fn (string $name, $parameters = []) => '/'.$name);
|
||||
|
||||
$row = fn (string $first, string $last, $value) => [
|
||||
'user_id' => 1, 'first_name' => $first, 'last_name' => $last,
|
||||
'profile_image_url' => null, 'value' => $value,
|
||||
];
|
||||
|
||||
$html = view('dashboard._top_results', [
|
||||
'monthlyStats' => [
|
||||
'month' => 8,
|
||||
'year' => 2026,
|
||||
'top_results' => [
|
||||
'top_volume' => [$row('Anna', 'Alpha', 150.0)],
|
||||
'top_recruiters' => [],
|
||||
'top_new_abos' => [$row('Ben', 'Beta', 3)],
|
||||
],
|
||||
],
|
||||
])->render();
|
||||
|
||||
expect($html)
|
||||
->toContain(e(__('home.top_results_title')))
|
||||
->toContain(__('home.top_results_volume'))
|
||||
->toContain('Anna Alpha')
|
||||
->toContain('Ben Beta')
|
||||
->toContain(__('home.top_results_empty')); // leere Recruiter-Spalte
|
||||
|
||||
$emptyHtml = view('dashboard._top_results', [
|
||||
'monthlyStats' => [
|
||||
'month' => 8,
|
||||
'year' => 2026,
|
||||
'top_results' => ['top_volume' => [], 'top_recruiters' => [], 'top_new_abos' => []],
|
||||
],
|
||||
])->render();
|
||||
|
||||
expect($emptyHtml)->not->toContain(e(__('home.top_results_title')));
|
||||
});
|
||||
|
|
@ -7,12 +7,14 @@ use App\Models\ShippingCountry;
|
|||
use App\Models\UserAbo;
|
||||
use App\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(Tests\TestCase::class)->in('Feature/Incentive');
|
||||
uses(Tests\TestCase::class)->in('Feature/Sys');
|
||||
uses(Tests\TestCase::class)->in('Unit/Incentive');
|
||||
uses(Tests\TestCase::class)->in('Unit/Dhl');
|
||||
uses(Tests\TestCase::class)->in('Feature/PaymentDashboard');
|
||||
uses(TestCase::class)->in('Feature/Incentive');
|
||||
uses(TestCase::class)->in('Feature/Sys');
|
||||
uses(TestCase::class)->in('Unit/Incentive');
|
||||
uses(TestCase::class)->in('Unit/Dhl');
|
||||
uses(TestCase::class)->in('Feature/PaymentDashboard');
|
||||
uses(TestCase::class)->in('Feature/SalesVolume');
|
||||
|
||||
if (! function_exists('makeShopEnv')) {
|
||||
function makeShopEnv(): void
|
||||
|
|
|
|||
|
|
@ -60,6 +60,34 @@ it('returns correct calculation months', function () {
|
|||
->and($months[4])->toBe(['month' => 8, 'year' => 2026]);
|
||||
});
|
||||
|
||||
it('qualificationWindow liefert Fensterstart 00:00:00 und Fensterende 23:59:59', function () {
|
||||
$incentive = new Incentive([
|
||||
'qualification_start' => '2026-04-01',
|
||||
'qualification_end' => '2026-07-31',
|
||||
'calculation_end' => '2026-08-31',
|
||||
]);
|
||||
|
||||
[$windowStart, $windowEnd] = $incentive->qualificationWindow();
|
||||
|
||||
expect($windowStart->format('Y-m-d H:i:s'))->toBe('2026-04-01 00:00:00')
|
||||
->and($windowEnd->format('Y-m-d H:i:s'))->toBe('2026-07-31 23:59:59');
|
||||
});
|
||||
|
||||
it('qualificationWindowContains zaehlt Zeitstempel am letzten Fenstertag (Randtag-Fall)', function () {
|
||||
$incentive = new Incentive([
|
||||
'qualification_start' => '2026-04-01',
|
||||
'qualification_end' => '2026-07-31',
|
||||
'calculation_end' => '2026-08-31',
|
||||
]);
|
||||
|
||||
expect($incentive->qualificationWindowContains(Carbon::parse('2026-07-31 11:05:52')))->toBeTrue()
|
||||
->and($incentive->qualificationWindowContains(Carbon::parse('2026-07-31 23:59:59')))->toBeTrue()
|
||||
->and($incentive->qualificationWindowContains(Carbon::parse('2026-04-01 00:00:00')))->toBeTrue()
|
||||
->and($incentive->qualificationWindowContains(Carbon::parse('2026-05-15 12:00:00')))->toBeTrue()
|
||||
->and($incentive->qualificationWindowContains(Carbon::parse('2026-03-31 23:59:59')))->toBeFalse()
|
||||
->and($incentive->qualificationWindowContains(Carbon::parse('2026-08-01 00:00:00')))->toBeFalse();
|
||||
});
|
||||
|
||||
// === Incentive: Status ===
|
||||
|
||||
it('detects status types correctly', function () {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue