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>
151 lines
5.7 KiB
PHP
151 lines
5.7 KiB
PHP
<?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');
|
|
});
|