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>
197 lines
7.1 KiB
PHP
197 lines
7.1 KiB
PHP
<?php
|
|
|
|
use App\Http\Controllers\HomeController;
|
|
use App\Models\UserAbo;
|
|
use App\Models\UserLevel;
|
|
use App\Services\Abo\PaylineForecastService;
|
|
use App\Services\BusinessPlan\LiveCommissionService;
|
|
use App\User;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Tests\TestCase;
|
|
|
|
uses(TestCase::class, RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
Carbon::setTestNow(Carbon::parse('2026-08-15'));
|
|
Cache::flush();
|
|
});
|
|
|
|
afterEach(function () {
|
|
Carbon::setTestNow();
|
|
});
|
|
|
|
function hmsMakeUser(?int $sponsorId = null, ?string $paymentAccount = 'future'): User
|
|
{
|
|
return User::forceCreate([
|
|
'email' => 'hms-'.uniqid('', true).'@example.com',
|
|
'password' => bcrypt('secret'),
|
|
'lang' => 'de',
|
|
'm_level' => 1,
|
|
'm_sponsor' => $sponsorId,
|
|
'active_date' => '2026-01-10',
|
|
'payment_account' => $paymentAccount === 'future' ? now()->addYear() : $paymentAccount,
|
|
]);
|
|
}
|
|
|
|
function hmsMonthlyStats(User $user, int $month = 8, int $year = 2026): array
|
|
{
|
|
Request::merge(['stats_month' => $month, 'stats_year' => $year]);
|
|
|
|
$method = (new ReflectionClass(HomeController::class))->getMethod('buildMonthlyStats');
|
|
$method->setAccessible(true);
|
|
|
|
return $method->invoke(new HomeController, $user);
|
|
}
|
|
|
|
it('zaehlt in der Firstlines-Kachel nur aktive Berater, konsistent zu "Teampartner gesamt"', function (): void {
|
|
$root = hmsMakeUser();
|
|
hmsMakeUser($root->id);
|
|
hmsMakeUser($root->id);
|
|
// Abgelaufenes, aber gesetztes Abrechnungskonto: zaehlt wie in der
|
|
// "Berater"-Spalte weiterhin als Berater (BackofficeDashboardService::isActiveConsultant).
|
|
hmsMakeUser($root->id, now()->subMonth()->toDateString());
|
|
// Gar kein Abrechnungskonto: kein Berater
|
|
hmsMakeUser($root->id, null);
|
|
|
|
$stats = hmsMonthlyStats($root);
|
|
|
|
// Beide Kacheln stehen im selben Cluster und muessen dieselbe Basis nutzen.
|
|
expect($stats['firstline_count'])->toBe(3)
|
|
->and($stats['team_partner_count'])->toBe(3);
|
|
});
|
|
|
|
it('zaehlt Neupartner weiterhin ueber alle Firstlines, nicht nur ueber die Kachel-Menge', function (): void {
|
|
$root = hmsMakeUser();
|
|
|
|
// Im laufenden Monat aktiv geworden, Konto gueltig -> Neupartner
|
|
hmsMakeUser($root->id)->forceFill(['active_date' => '2026-08-05'])->save();
|
|
// Im laufenden Monat aktiv geworden, Konto abgelaufen -> kein Neupartner,
|
|
// aber weiterhin Berater in der Firstlines-Kachel
|
|
hmsMakeUser($root->id, now()->subDay()->toDateString())
|
|
->forceFill(['active_date' => '2026-08-06'])->save();
|
|
// Ohne Abrechnungskonto: weder Neupartner noch Berater
|
|
hmsMakeUser($root->id, null)->forceFill(['active_date' => '2026-08-07'])->save();
|
|
|
|
$stats = hmsMonthlyStats($root);
|
|
|
|
expect($stats['direct_new_partners'])->toBe(1)
|
|
->and($stats['firstline_count'])->toBe(2);
|
|
});
|
|
|
|
/**
|
|
* Legt ein Karriere-Level an und haengt den User daran. Ohne Monatsqualifikation
|
|
* ist genau das die Ebenenzahl, mit der die beiden Payline-Kacheln rechnen.
|
|
*/
|
|
function hmsMakeLevel(int $pos, int $paylines, string $name = 'Testlevel'): UserLevel
|
|
{
|
|
return UserLevel::forceCreate([
|
|
'name' => $name.' '.$pos,
|
|
'qual_kp' => 150,
|
|
'qual_pp' => 0,
|
|
'paylines' => $paylines,
|
|
'pos' => $pos,
|
|
'active' => true,
|
|
]);
|
|
}
|
|
|
|
it('zeigt die Payline-Kacheln am Monatsanfang nach dem Karriere-Level, statt auf 0 zu fallen', function (): void {
|
|
// Ohne Monatsquali liefert der Live-Lauf paylines = 0. Die beiden
|
|
// Struktur-Kacheln muessen dann auf die Ebenen des Karriere-Levels
|
|
// zurueckfallen — die Teamstruktur aendert sich zum Monatswechsel nicht.
|
|
$level = hmsMakeLevel(pos: 3, paylines: 2);
|
|
|
|
$root = hmsMakeUser();
|
|
$root->forceFill(['m_level' => $level->id])->save();
|
|
|
|
$line1 = hmsMakeUser($root->id);
|
|
$line2 = hmsMakeUser($line1->id);
|
|
// Ebene 3 liegt ausserhalb der zwei bezahlten Ebenen des Karriere-Levels.
|
|
hmsMakeUser($line2->id);
|
|
|
|
$stats = hmsMonthlyStats($root->fresh());
|
|
|
|
expect($stats['payline_is_career_fallback'])->toBeTrue()
|
|
->and($stats['payline_member_count'])->toBe(2)
|
|
->and($stats['team_partner_count'])->toBe(3);
|
|
});
|
|
|
|
it('haelt eine leere Payline nicht im 6-h-Cache fest', function (): void {
|
|
// Ohne Karriere-Level bleibt die Tiefe 0. Das darf NICHT gecacht werden:
|
|
// der Zustand loest sich im Monatsverlauf von selbst auf.
|
|
$root = hmsMakeUser();
|
|
hmsMakeUser($root->id);
|
|
|
|
$service = app(PaylineForecastService::class);
|
|
$context = $service->paylineContext($root, 8, 2026);
|
|
|
|
expect($context['paylines'])->toBe(0)
|
|
->and($context['member_ids'])->toBe([])
|
|
->and(Cache::has("payline_context_{$root->id}_8_2026"))->toBeFalse();
|
|
});
|
|
|
|
it('cacht den Payline-Kontext, sobald eine Ebenenzahl feststeht', function (): void {
|
|
$level = hmsMakeLevel(pos: 3, paylines: 2);
|
|
|
|
$root = hmsMakeUser();
|
|
$root->forceFill(['m_level' => $level->id])->save();
|
|
hmsMakeUser($root->id);
|
|
|
|
$service = app(PaylineForecastService::class);
|
|
$context = $service->paylineContext($root->fresh(), 8, 2026);
|
|
|
|
expect($context['paylines'])->toBe(2)
|
|
->and(Cache::has("payline_context_{$root->id}_8_2026"))->toBeTrue();
|
|
});
|
|
|
|
it('blendet den Level-Block ohne Monatsqualifikation nicht aus, sondern zeigt die fehlenden Punkte', function (): void {
|
|
// setFirstQualLevel() liefert das Level auf pos 1 als next_level mit —
|
|
// daraus muss "dir fehlen noch X Punkte persoenliches Volumen" entstehen.
|
|
hmsMakeLevel(pos: 1, paylines: 3, name: 'Junior Berater');
|
|
|
|
$root = hmsMakeUser();
|
|
$root->forceFill(['m_level' => UserLevel::where('pos', 1)->value('id')])->save();
|
|
|
|
$level = app(LiveCommissionService::class)
|
|
->levelProgress($root->fresh(), 8, 2026);
|
|
|
|
expect($level)->not->toBeNull()
|
|
->and($level['is_qualified'])->toBeFalse()
|
|
->and($level['name'])->toBe('')
|
|
->and($level['next_name'])->toBe('Junior Berater 1')
|
|
->and($level['missing_personal'])->toBe(150.0);
|
|
});
|
|
|
|
it('rechnet Bestand und "+N neu" der Payline-Kachel nach derselben Regel', function (): void {
|
|
// Regression: der Bestand kam aus der Monatsend-Rueckrechnung, das Badge aus
|
|
// activeAbo(). In derselben Kachel standen damit zwei Zaehlweisen.
|
|
$level = hmsMakeLevel(pos: 3, paylines: 2);
|
|
|
|
$root = hmsMakeUser();
|
|
$root->forceFill(['m_level' => $level->id])->save();
|
|
$anna = hmsMakeUser($root->id);
|
|
|
|
$abo = UserAbo::create([
|
|
'user_id' => $anna->id,
|
|
'member_id' => $anna->id,
|
|
'shopping_user_id' => 0,
|
|
'email' => 'hms-abo@example.com',
|
|
'is_for' => 'ot',
|
|
'payone_userid' => 0,
|
|
'clearingtype' => 'cc',
|
|
'amount' => 5000,
|
|
'active' => false,
|
|
'status' => 2,
|
|
'abo_interval' => 5,
|
|
'start_date' => '2026-07-10',
|
|
'next_date' => '2026-08-25',
|
|
]);
|
|
// Erst im August beendet -> zaehlt im Juli sowohl im Bestand als auch als "neu".
|
|
$abo->update(['cancel_date' => '2026-08-05']);
|
|
|
|
$stats = hmsMonthlyStats($root->fresh(), month: 7, year: 2026);
|
|
|
|
expect($stats['payline_abo_count'])->toBe(1)
|
|
->and($stats['payline_abos_new'])->toBe(1);
|
|
});
|