mivita/tests/Feature/TopResultsServiceTest.php
Kevin 5a38f4ac3e Statistik: uebrige Audit-Korrekturen (Abo-Regeln, Prognose, Sortierung)
A01 — Abo-Regeln waren ueber das Aktiv-Feld umgehbar. Das
Selbstbedienungsformular hat gar keinen Aktiv-Schalter, es spiegelt den Zustand
nur in einem versteckten Feld zurueck; `updateStatus()` hat den Wert trotzdem
uebernommen. Wer den Request manipulierte, konnte sein eigenes Abo dauerhaft
deaktivieren und so die Mindestlaufzeit umgehen, am Pausenpfad mit seinen
Sperren vorbei. Das Feld wirkt jetzt nur noch im Admin-Pfad.

A16 — Statuswechsel wurden nur aus dem Formular protokolliert. Recovery-Zahlung,
Retry, externe Erledigung und die DSGVO-Anonymisierung schreiben `active` direkt
auf das Modell. Das ist heikel, weil die historische Bestandsrueckrechnung ein
Abo verwirft, solange eine protokollierte Deaktivierung nicht durch eine
spaetere protokollierte Reaktivierung aufgehoben ist — ein stiller
`active = true` nimmt es dauerhaft aus allen Folgemonaten.

Der Eintrag liegt deshalb jetzt am Model (`UserAbo::logStatusTransition()`) und
nicht in den einzelnen Diensten: so kann ihn ein kuenftiger Schreibpfad nicht
vergessen, und er entsteht in derselben Transaktion wie die Statusaenderung.
Nebeneffekt: Die DSGVO-Anonymisierung setzt `active = false` ohne `cancel_date`
— bisher fiel ein so abgeschaltetes Abo rueckwirkend aus allen vergangenen
Monaten, jetzt bleibt die Historie korrekt.

A13 — Ein heute faelliges Abo galt ab Mitternacht als erledigt, der
Ausfuehrungsjob laeuft aber erst um 04:00. In diesem Fenster fehlten die Punkte
in der Prognose UND im Ist. Der neue Scope `UserAbo::scopeOpenExecutionUntil()`
zaehlt den heutigen Liefertag mit, solange keine Ausfuehrung angelegt ist —
genau diese Bedingung verhindert die Doppelzaehlung. Alle drei Prognosen nutzen
jetzt denselben Scope statt je einer eigenen Kopie der Datumsregel.

A11 — Der Namens-Tiebreak der Top 3 lief nach dem Abschneiden auf 25
Kandidaten, bei Gleichstand entschied also die Reihenfolge der Abfrage. Die
Auswahlgrenze wird jetzt um alle Gleichstaende an dieser Grenze erweitert, und
Kandidaten ohne Account fallen vorher weg statt die Plaetze zu blockieren. Als
letzte Regel entscheidet die user_id, damit eine gecachte Rangliste
reproduzierbar bleibt.

A14 — Sortiert wurde ueber den angezeigten Zellentext. Unter `en` liefert die
Anwendung `Y-m-d` und `1,000.00`, damit ergab jedes Datum 0 und `1,000.00` wurde
zur 1. Die Zellen tragen jetzt `data-sort-value` mit dem unformatierten Wert;
ohne Attribut gilt weiter der Textweg. Dabei mitgenommen: Umlaute landeten hinter
dem Z (jetzt localeCompare), leere Werte lagen als 0 zwischen den negativen
Stornowerten, und `next_date` wurde doppelt formatiert.

Ausserdem A08 (Prognose auch ohne erreichte Qualifikationsstufe), A09
(Summenzeile im Neupartner-CSV), A10 (Lieferungszahl vorgeladen), A17
(Faelligkeitsgruppen ohne Monatsueberlauf), A18 (Formelinhalte im CSV als Text)
und A19 (fehlgeschlagene Provisionsberechnung ist als solche erkennbar statt
0,00 EUR).

Audit-Befunde A01, A08, A09, A10, A11, A13, A14, A16, A17, A18, A19.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 11:19:38 +00:00

324 lines
12 KiB
PHP

<?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, mit Betrachter, ohne 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 mit ("ich")
trsAddVolume($fremd->id, 800, 1); // nicht im Team
$result = trsTop3($root);
expect($result['top_volume'])->toHaveCount(3)
->and($result['top_volume'][0])->toMatchArray(['user_id' => $root->id, 'value' => 500.0, 'is_self' => true])
->and($result['top_volume'][1])->toMatchArray(['user_id' => $anna->id, 'first_name' => 'Anna', 'last_name' => 'Alpha', 'value' => 150.0, 'is_self' => false])
->and($result['top_volume'][1]['profile_image_url'])->toBeNull()
->and($result['top_volume'][2])->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('beruecksichtigt Gleichstaende jenseits des Auswahlfensters (A11)', function (): void {
// Der Namens-Tiebreak darf nicht an der Groesse des Kandidatenfensters
// haengen: "Alpha" wird zuletzt angelegt und liegt mit 25 anderen gleichauf.
$root = trsMakeUser(null, 'Root', 'Viewer');
for ($i = 0; $i < 25; $i++) {
$zulu = trsMakeUser($root->id, 'Zulu'.str_pad((string) $i, 2, '0', STR_PAD_LEFT), 'Zett');
trsAddVolume($zulu->id, 100, 1);
}
$alpha = trsMakeUser($root->id, 'Alpha', 'Anfang');
trsAddVolume($alpha->id, 100, 1);
$result = trsTop3($root);
expect($result['top_volume'])->toHaveCount(3)
->and($result['top_volume'][0]['user_id'])->toBe($alpha->id)
->and($result['top_volume'][0]['first_name'])->toBe('Alpha');
});
it('fuellt die Top 3 auch auf, wenn viele Kandidaten keinen Account haben (A11)', function (): void {
$root = trsMakeUser(null, 'Root', 'Viewer');
// Kandidaten ohne Account koennen nicht angezeigt werden; sie duerfen die
// Auswahl aber auch nicht verstopfen.
for ($i = 0; $i < 25; $i++) {
$orphan = User::forceCreate([
'email' => 'trs-orphan-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'm_level' => 1,
'm_sponsor' => $root->id,
'payment_account' => now()->addYear(),
]);
trsAddVolume($orphan->id, 500, 1);
}
$berta = trsMakeUser($root->id, 'Berta', 'Bach');
trsAddVolume($berta->id, 100, 1);
$result = trsTop3($root);
expect(array_column($result['top_volume'], 'user_id'))->toBe([$berta->id]);
});
it('sortiert Namensgleichheit reproduzierbar nach user_id (A11)', function (): void {
$root = trsMakeUser(null, 'Root', 'Viewer');
$first = trsMakeUser($root->id, 'Maria', 'Muster');
$second = trsMakeUser($root->id, 'Maria', 'Muster');
$third = trsMakeUser($root->id, 'Maria', 'Muster');
foreach ([$first, $second, $third] as $user) {
trsAddVolume($user->id, 100, 1);
}
expect(array_column(trsTop3($root)['top_volume'], 'user_id'))
->toBe([$first->id, $second->id, $third->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 mit
$result = trsTop3($root);
// Gleichstand 1:1 zwischen Olaf und Root -> nach Vorname sortiert
expect($result['top_recruiters'])->toHaveCount(3)
->and($result['top_recruiters'][0])->toMatchArray(['user_id' => $sponsor->id, 'value' => 2, 'is_self' => false])
->and($result['top_recruiters'][1])->toMatchArray(['user_id' => $other->id, 'value' => 1])
->and($result['top_recruiters'][2])->toMatchArray(['user_id' => $root->id, 'value' => 1, 'is_self' => true]);
});
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 mit
trsMakeAbo($fremd->id, 'ot', '2026-08-09'); // nicht im Team
$result = trsTop3($root);
// Gleichstand 1:1 zwischen Ben und Root -> nach Vorname sortiert
expect($result['top_new_abos'])->toHaveCount(3)
->and($result['top_new_abos'][0])->toMatchArray(['user_id' => $anna->id, 'value' => 3, 'is_self' => false])
->and($result['top_new_abos'][1])->toMatchArray(['user_id' => $ben->id, 'value' => 1])
->and($result['top_new_abos'][2])->toMatchArray(['user_id' => $root->id, 'value' => 1, 'is_self' => true]);
});
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('zeigt ein neues Profilfoto sofort, trotz gecachter Rangliste', function (): void {
Storage::fake('public');
$root = trsMakeUser(null, 'Root', 'Viewer');
$anna = trsMakeUser($root->id, 'Anna', 'Alpha');
trsAddVolume($anna->id, 100, 1);
$service = app(TopResultsService::class);
expect($service->monthlyTop3($root, 8, 2026)['top_volume'][0]['profile_image_url'])->toBeNull();
// Foto wird hochgeladen, nachdem die Rangliste bereits im Cache liegt
Storage::disk('public')->put('images/profile/anna.jpg', 'x');
$anna->account->forceFill(['profile_image' => 'anna.jpg'])->save();
expect($service->monthlyTop3($root, 8, 2026)['top_volume'][0]['profile_image_url'])
->toContain('images/profile/anna.jpg');
});
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')));
});
it('haelt "Top 3 neue Abos" fuer abgeschlossene Monate stabil, auch wenn Abos spaeter enden', function (): void {
// Regression: die Liste zaehlte ueber activeAbo() und schrumpfte damit
// rueckwirkend, sobald ein Abo endete — anders als die Top-3-Neupartner
// daneben, die ueber newPartnerIn() bereits stichtagsstabil sind.
$root = trsMakeUser(null, 'Root', 'Viewer');
$anna = trsMakeUser($root->id, 'Anna', 'Alpha');
// Im Juli gestartet, erst im August gekuendigt: gehoert in die Juli-Zahl.
trsMakeAbo($anna->id, 'ot', '2026-07-10', active: false)
->update(['cancel_date' => '2026-08-05']);
trsMakeAbo($anna->id, 'ot', '2026-07-20');
// Im August gestartet: gehoert nicht in die Juli-Zahl.
trsMakeAbo($anna->id, 'ot', '2026-08-02');
Cache::flush();
$result = app(TopResultsService::class)->monthlyTop3($root, 7, 2026);
expect($result['top_new_abos'])->toHaveCount(1)
->and($result['top_new_abos'][0]['first_name'])->toBe('Anna')
->and($result['top_new_abos'][0]['value'])->toBe(2);
});