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>
This commit is contained in:
Kevin 2026-09-10 11:19:38 +00:00
parent 8d7a98c685
commit 94e2b13384
31 changed files with 1931 additions and 241 deletions

View file

@ -31,8 +31,11 @@ afterEach(function () {
Carbon::setTestNow();
});
function pflMakeUser(?int $sponsorId = null): User
function pflMakeUser(?int $sponsorId = null, ?string $activeDate = '2026-01-10'): User
{
// active_date ist gesetzt wie in der Produktion: der Provisionsbaum nimmt nur
// Teampartner auf, die bis zum Monatsende aktiv geworden sind
// (BusinessUserItemOptimized::readParentsBusinessUsers()).
return User::forceCreate([
'email' => 'pfl-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
@ -40,6 +43,7 @@ function pflMakeUser(?int $sponsorId = null): User
'm_level' => 1,
'm_sponsor' => $sponsorId,
'payment_account' => now()->addYear(),
'active_date' => $activeDate,
]);
}
@ -85,11 +89,14 @@ function pflMakeAbo(int $userId, string $isFor, string $nextDate, int $points, i
return $abo->fresh();
}
function pflSeedContext(User $user, array $memberIds, int $paylines = 2): void
function pflSeedContext(User $user, array $memberIds, int $paylines = 2, ?int $month = null, ?int $year = null): void
{
$month ??= (int) now()->month;
$year ??= (int) now()->year;
Cache::put(
"payline_context_{$user->id}_".now()->month.'_'.now()->year,
['paylines' => $paylines, 'member_ids' => $memberIds],
"payline_context_{$user->id}_{$month}_{$year}",
['paylines' => $paylines, 'member_ids' => $memberIds, 'is_career_fallback' => false],
now()->addHour()
);
}
@ -135,6 +142,67 @@ it('markiert vergangene Termine und zaehlt nur noch offene Ausfuehrungen', funct
->and($forecast['days'][3])->toMatchArray(['day' => 25, 'upcoming' => 20.0, 'total' => 120.0, 'is_past' => false]);
});
it('zaehlt eine heute faellige, noch nicht ausgefuehrte Lieferung mit (A13)', function (): void {
// 10.07., 00:01 Uhr: der Ausfuehrungsjob laeuft erst um 04:00. Bis dahin ist
// das Abo weder im Ist gebucht noch — bisher — in der Prognose enthalten.
Carbon::setTestNow(Carbon::parse('2026-07-10 00:01:00'));
$root = pflMakeUser();
$anna = pflMakeUser($root->id);
pflSeedContext($root, [$anna->id]);
pflMakeAbo($anna->id, 'ot', '2026-07-10', 50);
$forecast = app(PaylineForecastService::class)->forecastDays($root, 0.0);
expect($forecast['days'][1])->toMatchArray(['day' => 10, 'upcoming' => 50.0])
->and($forecast['days'][3])->toMatchArray(['day' => 25, 'upcoming' => 50.0]);
});
it('zaehlt eine heute bereits ausgefuehrte Lieferung nicht doppelt (A13)', function (): void {
Carbon::setTestNow(Carbon::parse('2026-07-10 06:00:00'));
$root = pflMakeUser();
$anna = pflMakeUser($root->id);
pflSeedContext($root, [$anna->id]);
// Der Job hat die Ausfuehrung angelegt; die Punkte stecken damit im Ist.
$abo = pflMakeAbo($anna->id, 'ot', '2026-07-10', 50);
$abo->user_abo_orders()->create(['shopping_order_id' => 0, 'status' => 1, 'paid' => true]);
$forecast = app(PaylineForecastService::class)->forecastDays($root, 0.0);
expect($forecast['days'][1])->toMatchArray(['day' => 10, 'upcoming' => 0.0])
->and($forecast['days'][3])->toMatchArray(['day' => 25, 'upcoming' => 0.0]);
});
it('zaehlt eine gestern faellige Lieferung nicht mehr mit (A13)', function (): void {
// Nur der heutige Liefertag ist offen; ein liegengebliebener Vortag gehoert
// in die Nachbearbeitung, nicht in die Prognose.
Carbon::setTestNow(Carbon::parse('2026-07-11 09:00:00'));
$root = pflMakeUser();
$anna = pflMakeUser($root->id);
pflSeedContext($root, [$anna->id]);
pflMakeAbo($anna->id, 'ot', '2026-07-10', 50);
$forecast = app(PaylineForecastService::class)->forecastDays($root, 0.0);
expect($forecast['days'][3])->toMatchArray(['day' => 25, 'upcoming' => 0.0]);
});
it('zaehlt den heutigen Liefertag auch in der persoenlichen Vorschau (A13)', function (): void {
Carbon::setTestNow(Carbon::parse('2026-07-10 00:01:00'));
$berater = pflMakeUser();
pflMakeAbo($berater->id, 'ot', '2026-07-10', 50);
$forecast = app(PaylineForecastService::class)->personalCustomerAboForecast($berater);
expect($forecast['days'][1])->toMatchArray(['day' => 10, 'upcoming' => 50.0]);
});
it('liefert Soll und Ist des Payline-Abo-Umsatzes', function (): void {
$root = pflMakeUser();
$anna = pflMakeUser($root->id);
@ -327,3 +395,171 @@ it('prognostiziert Level und Provision zum letzten Liefertag', function (): void
->and($forecast['estimated_level_name'])->toBe('Gold')
->and($forecast['estimated_commission'])->toBe(63.0);
});
it('haelt das "+N neu"-Badge der Payline-Kachel fuer abgeschlossene Monate stabil', function (): void {
// Regression: das Badge zaehlte ueber activeAbo() (heutiger Stand), der
// Bestand daneben ueber die Monatsend-Rueckrechnung. Ein im Juli gestartetes
// und im August beendetes Abo fiel damit aus der Juli-Zahl heraus.
Carbon::setTestNow(Carbon::parse('2026-08-15'));
$root = pflMakeUser();
$anna = pflMakeUser($root->id);
pflSeedContext($root, [$anna->id], month: 7, year: 2026);
pflMakeAbo($anna->id, 'ot', '2026-08-25', 30)
->update(['start_date' => '2026-07-05', 'active' => false, 'cancel_date' => '2026-08-05']);
pflMakeAbo($anna->id, 'me', '2026-08-10', 20)->update(['start_date' => '2026-07-03']);
// Im August gestartet: gehoert nicht in die Juli-Zahl.
pflMakeAbo($anna->id, 'ot', '2026-08-20', 15)->update(['start_date' => '2026-08-02']);
expect(app(PaylineForecastService::class)->newPaylineAboCount($root, 7, 2026))->toBe(2);
});
it('rechnet die Prognose ueber denselben Baum wie der Provisionslauf', function (): void {
// lineBuckets() nimmt jeden Downline-User, der TreeCalcBot filtert und kappt
// den Unterbaum darunter. Ohne denselben Filter zaehlte die Prognose Punkte
// mit, die in den payline_points der Level-Kachel gar nicht stecken —
// zwei verschiedene "fehlen noch X Punkte" auf einer Seite.
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();
// Ebene 1 ohne Abrechnungskonto: steht nicht im Provisionsbaum ...
$inaktiv = pflMakeUser($root->id);
$inaktiv->forceFill(['payment_account' => null])->save();
// ... und damit auch nicht sein Teampartner auf Ebene 2.
$unterInaktiv = pflMakeUser($inaktiv->id);
// Ebene 1, erst nach dem Monatsende aktiv geworden: ebenfalls draussen.
pflMakeUser($root->id, '2026-08-15');
$anna = pflMakeUser($root->id); // zaehlt
$levelBase = [
'qual_kp' => 0, 'growth_bonus' => 0,
'pr_line_1' => 10, 'pr_line_2' => 0, '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' => 100.0,
'payline_points_qual_kp' => 100.0,
'sales_volume_points_KP_sum' => 0.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' => 0]),
'qual_user_level_next' => array_merge($levelBase, ['name' => 'Diamant', 'paylines' => 2, 'qual_pp' => 1000, 'qual_kp' => 0]),
]);
foreach ([$inaktiv->id => 500, $unterInaktiv->id => 300, $anna->id => 100] as $userId => $points) {
UserSalesVolume::forceCreate([
'user_id' => $userId, 'month' => 7, 'year' => 2026,
'points' => $points, 'status' => 1, 'month_TP_points' => $points, 'month_shop_points' => 0,
]);
}
$forecast = app(PaylineForecastService::class)->levelAndCommissionForecast($root, 7, 2026);
// Nur Annas 100 Punkte zaehlen -> zu 1000 fehlen 900 (nicht 1000 - 900 = 100).
expect($forecast['missing_pp'])->toBe(900.0)
->and($forecast['estimated_commission'])->toBe(10.0);
});
it('prognostiziert die Liefertage ausschliesslich aus den eigenen Kundenabos', function (): void {
// Kundenwunsch 2026-09-07: "wieviel Volumenpunkte habe ich am 5./10./20./25.
// Nur mit meinen Kundenabos" — ohne eigenes Abo, ohne Team.
$root = pflMakeUser();
$anna = pflMakeUser($root->id);
pflMakeAbo($root->id, 'ot', '2026-07-10', 30); // eigenes Kundenabo
pflMakeAbo($root->id, 'ot', '2026-07-25', 45); // eigenes Kundenabo
pflMakeAbo($root->id, 'me', '2026-07-10', 99); // eigenes Abo zaehlt NICHT
pflMakeAbo($anna->id, 'ot', '2026-07-05', 77); // Team zaehlt NICHT
pflMakeAbo($root->id, 'ot', '2026-08-05', 50); // naechster Monat zaehlt nicht
$forecast = app(PaylineForecastService::class)->personalCustomerAboForecast($root);
expect($forecast['base'])->toBe(0.0)
->and($forecast['days'])->toHaveCount(4)
->and($forecast['days'][0])->toMatchArray(['day' => 5, 'upcoming' => 0.0, 'total' => 0.0])
->and($forecast['days'][1])->toMatchArray(['day' => 10, 'upcoming' => 30.0, 'total' => 30.0])
->and($forecast['days'][2])->toMatchArray(['day' => 20, 'upcoming' => 30.0, 'total' => 30.0])
->and($forecast['days'][3])->toMatchArray(['day' => 25, 'upcoming' => 75.0, 'total' => 75.0]);
});
it('prognostiziert auch ohne bereits erreichte Qualifikationsstufe', function (): void {
// A08: Wer im Monat noch keine Stufe qualifiziert hat, kann die erste durch
// kommende eigene Kundenabo-Punkte trotzdem erreichen. Vorher lieferte die
// Prognose in diesem Zustand gar nichts.
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();
pflSeedContext($root, []);
UserBusiness::forceCreate([
'user_id' => $root->id,
'month' => 7,
'year' => 2026,
'version' => 1,
'payline_points' => 0.0,
'payline_points_qual_kp' => 0.0,
'sales_volume_points_KP_sum' => 0.0,
'commission_pp_total' => 0,
'commission_shop_sales' => 0,
'commission_growth_total' => 0,
'qual_user_level' => null, // noch keine Stufe erreicht
'qual_user_level_next' => [
'name' => 'Start', 'paylines' => 1, 'qual_pp' => 0, 'qual_kp' => 50, 'growth_bonus' => 0,
'pr_line_1' => 7, 'pr_line_2' => 0, 'pr_line_3' => 0, 'pr_line_4' => 0,
'pr_line_5' => 0, 'pr_line_6' => 0, 'pr_line_7' => 0, 'pr_line_8' => 0,
],
]);
// Eigenes Kundenabo mit 50 Punkten, faellig vor Monatsende
pflMakeAbo($root->id, 'ot', '2026-07-20', 50);
$forecast = app(PaylineForecastService::class)->levelAndCommissionForecast($root, 7, 2026);
expect($forecast)->not->toBeNull()
->and($forecast['next_name'])->toBe('Start')
->and($forecast['personal_forecast'])->toBe(50.0)
->and($forecast['missing_personal'])->toBe(0.0)
->and($forecast['reached'])->toBeTrue()
->and($forecast['estimated_level_name'])->toBe('Start');
});
it('liefert ohne erreichte und ohne naechste Stufe weiterhin keine Prognose', 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();
});
}
}
$root = pflMakeUser();
pflSeedContext($root, []);
UserBusiness::forceCreate([
'user_id' => $root->id,
'month' => 7,
'year' => 2026,
'version' => 1,
'payline_points' => 0.0,
'sales_volume_points_KP_sum' => 0.0,
'qual_user_level' => null,
'qual_user_level_next' => null,
]);
expect(app(PaylineForecastService::class)->levelAndCommissionForecast($root, 7, 2026))->toBeNull();
});