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:
parent
8d7a98c685
commit
94e2b13384
31 changed files with 1931 additions and 241 deletions
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue