mivita/tests/Feature/TeamAboOverviewTest.php
Kevin cee4a2e0eb Statistiken: Review-Fixes vor Livegang (Fehldarstellungen + Test-Regression)
Ergebnis des Senior-Reviews über dev/2026-07-22 (Konzept 22.07., Feedback 24.07.,
Plan 19.08.). Rechnerisch war nichts zu korrigieren: Payline-Punkte und Provision
des Prognose-Blocks (N4) stimmen exakt mit der Provisions-Engine überein
(54.718,5 P. bzw. 3.772,88 EUR gegengerechnet an User 454). Behoben sind
Darstellungs- und Konsistenzfehler, die der Beraterin als falsche Zahlen
aufgefallen wären:

- "+N neu" schrumpfte rückwirkend, der Bestand daneben nicht: newAboCount() nutzte
  weiter activeAbo(), während aboStockCount() auf das Monatsende zurückrechnet.
  Beide teilen sich jetzt aboStockQuery(); die Jahres-Wachstumstabelle ebenso.
- Level-Kachel widersprach sich ("Zum nächsten Level Diamant fehlen X" über
  "Karriere-Level: Diamant"): Text auf die Monats-Qualifikation umformuliert
  (de/en/es/fr), Überschrift "Qualifikation in diesem Monat", Karriere-Level nur
  noch bei Abweichung.
- Firstlines-Kachel zählte alle direkten Partner, "Teampartner gesamt" daneben nur
  aktive (F1): Kachel nutzt jetzt isActiveConsultant(), die Detailseite listet
  weiter alle und weist beide Zahlen aus.
- Neupartnerzahlen vergangener Monate sanken auf dem Home-Dashboard, sobald
  Jahresgebühren ausliefen: neue User::newPartnerReferenceDate() (laufender Monat =
  heute, abgeschlossener Monat = Monatsende). Dabei die drei Inline-Kopien der
  Neupartner-Regel auf scopeNewPartnerIn()/isNewPartnerIn() zusammengeführt.
- Top-3-Karte ließ den Betrachter aus, obwohl "ich gehört in die Liste" in der
  Juli-Runde für alle anderen Ranglisten umgesetzt wurde: zählt jetzt mit, inkl.
  is_self und "ich"-Badge.
- Neues Profilfoto zeigte bis zu 6 h ein totes Bild: die URL steckt nicht mehr im
  Cache, sondern entsteht beim Lesen (withProfileImages()).
- Test-Regression seit der Juli-Runde: Unit/Services/BackofficeDashboardServiceTest
  fiel über die fehlende Spalte status_points (kam mit nonPaylinePoints() dazu).
- Kleinigkeiten: ProfileImageController gab die komplette Exception als
  Flash-Message aus (jetzt Log + Sprachkey), BackofficeDrilldownService formatierte
  Datumsangaben hart auf d.m.Y statt über Util::formatDateDB().

Tests: neuer HomeMonthlyStatsTest (buildMonthlyStats war ungetestet) plus Tests für
Stichtags-Zählung und Foto-Cache. Fünf Tests kodierten das alte Verhalten
(Betrachter zählt nicht mit, abgelaufener Account zählt nie) und wurden auf die
neue Semantik umgestellt, jeweils mit Gegenprobe. Suite: 861 grün (vorher 855),
89 rot — ausschließlich die in docs/2026-06-11-test-suite-vorbestehende-fehler.md
dokumentierten Altlasten.

Offen und bewusst nicht enthalten:
- Deploy-Schritt: backoffice:store-statistics-snapshots --force (vorher dumpen).
  Kein vorhandener Snapshot enthält recruiter_ranking, Mai/Juni zusätzlich weder
  non_payline_points noch die Führungskräfte-"gesamt"-Spalten; der Cron überspringt
  vorhandene Snapshots ohne --force. Achtung: die Neuberechnung ist nicht
  verlustfrei.
- TreeHtmlRendererTest/TreeCalcBotIntegrationTest: die Abo-Badges machten den
  Renderer datenbankabhängig; die Dateien waren schon vorher rot und gehören in die
  separate Test-Aufräumaufgabe.
- deleteImage bleibt ein GET-Route (gleiches Muster wie das Shop-Bild).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 09:41:53 +00:00

433 lines
16 KiB
PHP

<?php
use App\Models\Product;
use App\Models\ShoppingOrder;
use App\Models\UserAbo;
use App\Models\UserAboItem;
use App\Models\UserAboOrder;
use App\Models\UserAboStatusHistory;
use App\Models\UserSalesVolume;
use App\Services\Abo\TeamAboOverviewService;
use App\Services\HTMLHelper;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\URL;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
if (! Schema::connection('sqlite')->hasColumn('products', 'slug')) {
Schema::connection('sqlite')->table('products', function ($table) {
$table->string('slug')->nullable();
});
}
});
function makeUser(?int $sponsorId = null): User
{
$user = User::forceCreate([
'email' => 'u-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'm_level' => 1,
'm_sponsor' => $sponsorId,
'payment_account' => now()->addYear(),
]);
return $user;
}
function makeAbo(int $userId, int $memberId, string $isFor, string $startDate, int $points = 0): UserAbo
{
$abo = UserAbo::create([
'user_id' => $userId,
'member_id' => $memberId,
'shopping_user_id' => 0,
'email' => 'a-'.uniqid('', true).'@example.com',
'is_for' => $isFor,
'payone_userid' => 0,
'clearingtype' => 'cc',
'amount' => 5000,
'active' => true,
'status' => 2,
'abo_interval' => 5,
'start_date' => $startDate,
'last_date' => $startDate,
'next_date' => '2026-12-10',
]);
if ($points > 0) {
$product = Product::forceCreate([
'name' => 'P-'.uniqid('', true),
'title' => 'Test',
'price' => 10.0,
'tax' => 19.0,
'active' => true,
'pos' => 1,
'weight' => 100,
'points' => $points,
'slug' => 'p-'.uniqid('', true),
]);
UserAboItem::create([
'user_abo_id' => $abo->id,
'product_id' => $product->id,
'comp' => 0,
'qty' => 1,
'status' => 1,
]);
}
return $abo->fresh();
}
it('gruppiert Team-Abos nach Ebenen mit Eigen- und Kundenabos', function (): void {
$root = makeUser();
// 1. Ebene: zwei direkte Partner
$anna = makeUser($root->id);
$lena = makeUser($root->id);
// 2. Ebene: ein Partner unter Anna
$lisa = makeUser($anna->id);
// Anna hat ein Eigen-Abo (50 Punkte) und 2 Kundenabos
makeAbo($anna->id, $anna->id, 'me', '2026-06-05', 50);
makeAbo($anna->id, $anna->id, 'ot', '2026-06-10', 20);
makeAbo($anna->id, $anna->id, 'ot', '2026-05-10', 30);
// Lena hat kein Eigen-Abo, aber 1 Kundenabo
makeAbo($lena->id, $lena->id, 'ot', '2026-06-12', 15);
// Lisa (Ebene 2) hat ein Eigen-Abo
makeAbo($lisa->id, $lisa->id, 'me', '2026-06-01', 40);
$service = app(TeamAboOverviewService::class);
$overview = $service->linesOverview($root, 6, 2026);
expect($overview['lines'])->toHaveKey(1)
->and($overview['lines'])->toHaveKey(2);
$line1 = $overview['lines'][1];
expect($line1['consultants'])->toBe(2)
->and($line1['members_with_own_abo'])->toBe(1) // nur Anna
->and($line1['own_abos'])->toBe(1)
->and($line1['customer_abos'])->toBe(3) // Anna 2 + Lena 1
->and($line1['customer_abos_new'])->toBe(2) // im Juni: Anna 1 + Lena 1 (Mai zählt nicht)
->and($line1['own_abos_new'])->toBe(1);
$line2 = $overview['lines'][2];
expect($line2['consultants'])->toBe(1)
->and($line2['own_abos'])->toBe(1)
->and($line2['customer_abos'])->toBe(0);
$totals = $overview['totals'];
expect($totals['own_abos'])->toBe(2)
->and($totals['customer_abos'])->toBe(3)
->and($totals['total_abos'])->toBe(5);
});
it('zählt nur aktive Berater und blendet inaktive/fehlerhafte aus', function (): void {
$root = makeUser();
// Aktiver Berater (m_level + gültiges payment_account)
$anna = makeUser($root->id);
// Inaktiv: kein payment_account
$noPayment = makeUser($root->id);
$noPayment->forceFill(['payment_account' => null])->save();
// Inaktiv: keine Mitgliedsebene
$noLevel = makeUser($root->id);
$noLevel->forceFill(['m_level' => null])->save();
// Abos der inaktiven Berater dürfen nicht in die Übersicht einfließen
makeAbo($anna->id, $anna->id, 'me', '2026-06-05', 50);
makeAbo($noPayment->id, $noPayment->id, 'me', '2026-06-05', 10);
makeAbo($noLevel->id, $noLevel->id, 'ot', '2026-06-05', 10);
$service = app(TeamAboOverviewService::class);
$overview = $service->linesOverview($root, 6, 2026);
$line1 = $overview['lines'][1];
expect($line1['consultants'])->toBe(1)
->and($line1['members'])->toHaveCount(1)
->and($line1['members'][0]['user_id'])->toBe($anna->id)
->and($line1['own_abos'])->toBe(1)
->and($line1['customer_abos'])->toBe(0)
->and($overview['totals']['consultants'])->toBe(1);
});
it('liefert je Ebene abgerechnete Abo-Punkte und die Prognose bis zum letzten Liefertag', function (): void {
Carbon\Carbon::setTestNow('2026-06-10');
$root = makeUser();
$anna = makeUser($root->id);
// Offene Ausfuehrung bis zum 25.: zaehlt in der Prognose
makeAbo($anna->id, $anna->id, 'me', '2026-05-05', 50)->update(['next_date' => '2026-06-20']);
// Naechste Ausfuehrung erst im Folgemonat: zaehlt nicht
makeAbo($anna->id, $anna->id, 'ot', '2026-05-10', 30)->update(['next_date' => '2026-07-05']);
// Bereits abgerechnete Abo-Punkte im Juni
$order = ShoppingOrder::forceCreate([
'shopping_user_id' => 0,
'auth_user_id' => $anna->id,
'country_id' => 0,
'user_shop_id' => 0,
'is_abo' => true,
'paid' => true,
]);
UserSalesVolume::forceCreate([
'user_id' => $anna->id,
'shopping_order_id' => $order->id,
'month' => 6,
'year' => 2026,
'points' => 30,
'status' => 2,
]);
$overview = app(TeamAboOverviewService::class)->linesOverview($root, 6, 2026);
$line1 = $overview['lines'][1];
expect($line1['abo_points_billed'])->toBe(30.0)
->and($line1['abo_points_expected'])->toBe(80.0) // 30 abgerechnet + 50 offen bis 20.06.
->and($overview['totals']['abo_points_billed'])->toBe(30.0)
->and($overview['totals']['abo_points_expected'])->toBe(80.0);
Carbon\Carbon::setTestNow();
});
it('liefert Abo-Bestaende monatsbezogen: vergangene Monate zeigen den Bestand zum Monatsende', function (): void {
Carbon\Carbon::setTestNow('2026-08-15');
$root = makeUser();
$anna = makeUser($root->id);
// Läuft seit Mai bis heute: zählt in Mai, Juli und im Live-Monat August
makeAbo($anna->id, $anna->id, 'me', '2026-05-10');
// Im Juli gekündigt: zählt im Juni, nicht mehr im Juli-Bestand (Stichtag 31.07.)
makeAbo($anna->id, $anna->id, 'ot', '2026-06-20')
->update(['status' => 4, 'active' => false, 'cancel_date' => '2026-07-15']);
// Im Juli soft-geloescht: zählt im Juni, nicht mehr im Juli
$deleted = makeAbo($anna->id, $anna->id, 'ot', '2026-05-01');
$deleted->delete();
UserAbo::withTrashed()->whereKey($deleted->id)->update(['deleted_at' => '2026-07-10']);
// Heute inaktiv, aber laut Status-Historie erst im August deaktiviert: zählt im Juli noch
$paused = makeAbo($anna->id, $anna->id, 'ot', '2026-06-01');
$paused->update(['active' => false]);
UserAboStatusHistory::forceCreate([
'user_abo_id' => $paused->id,
'action' => UserAboStatusHistory::ACTION_DEACTIVATED,
'created_at' => '2026-08-02',
]);
// Laut Historie schon im Juni deaktiviert, erst im August geloescht: darf
// im Juni/Juli NICHT wiederauferstehen (spaeteres Ereignis heilt nichts)
$deactivatedEarly = makeAbo($anna->id, $anna->id, 'ot', '2026-05-20');
$deactivatedEarly->update(['active' => false]);
UserAboStatusHistory::forceCreate([
'user_abo_id' => $deactivatedEarly->id,
'action' => UserAboStatusHistory::ACTION_DEACTIVATED,
'created_at' => '2026-06-10',
]);
$deactivatedEarly->delete();
UserAbo::withTrashed()->whereKey($deactivatedEarly->id)->update(['deleted_at' => '2026-08-05']);
$svc = app(TeamAboOverviewService::class);
$august = $svc->teamKpis($root, 8, 2026);
expect($august['team_partner_abos'])->toBe(1)
->and($august['team_customer_abos'])->toBe(0); // live: gekündigt/gelöscht/deaktiviert zählen nicht
$juli = $svc->teamKpis($root, 7, 2026);
expect($juli['team_partner_abos'])->toBe(1)
->and($juli['team_customer_abos'])->toBe(1); // nur das erst im August deaktivierte
$juni = $svc->teamKpis($root, 6, 2026);
expect($juni['team_customer_abos'])->toBe(3); // gekündigtes + gelöschtes + später deaktiviertes
$mai = $svc->teamKpis($root, 5, 2026);
expect($mai['team_partner_abos'])->toBe(1)
->and($mai['team_customer_abos'])->toBe(2); // Mai-Kundenabo (gelöscht 07/26) + das erst im Juni deaktivierte
Carbon\Carbon::setTestNow();
});
it('zaehlt "+N neu" mit derselben Stichtags-Logik wie den Bestand', function (): void {
Carbon\Carbon::setTestNow('2026-08-15');
$root = makeUser();
$anna = makeUser($root->id);
// Im Juni gestartet, erst im Juli gekuendigt: gehoert in den Juni-Bestand
// UND in die Juni-Zugaenge - frueher fiel es aus "+N neu" heraus, weil es
// heute nicht mehr aktiv ist.
makeAbo($anna->id, $anna->id, 'ot', '2026-06-20')
->update(['status' => 4, 'active' => false, 'cancel_date' => '2026-07-15']);
// Im Juni gestartet und noch im Juni gekuendigt: zaehlt in keiner Juni-Zahl.
makeAbo($anna->id, $anna->id, 'ot', '2026-06-02')
->update(['status' => 4, 'active' => false, 'cancel_date' => '2026-06-20']);
$svc = app(TeamAboOverviewService::class);
$juni = $svc->teamKpis($root, 6, 2026);
expect($juni['team_customer_abos'])->toBe(1)
->and($juni['team_customer_abos_new'])->toBe(1);
// Wachstumstabelle nutzt dieselbe Zaehlung
$growth = collect($svc->monthlyGrowth($root, 2026)['months'])->firstWhere('month', 6);
expect($growth['customer_new'])->toBe(1);
Carbon\Carbon::setTestNow();
});
it('liefert pro Mitglied das Eigen-Abo-Flag und Punkte', function (): void {
$root = makeUser();
$anna = makeUser($root->id);
makeAbo($anna->id, $anna->id, 'me', '2026-06-05', 50);
makeAbo($anna->id, $anna->id, 'ot', '2026-06-10', 20);
$service = app(TeamAboOverviewService::class);
$overview = $service->linesOverview($root, 6, 2026);
$member = collect($overview['lines'][1]['members'])->firstWhere('user_id', $anna->id);
expect($member['has_own_abo'])->toBeTrue()
->and($member['own_abo_points'])->toBe(50.0)
->and($member['customer_abo_count'])->toBe(1)
->and($member['customer_abo_points'])->toBe(20.0)
->and($member['total_abos'])->toBe(2);
});
it('rendert die Team-Abo-Linienansicht mit Ebenen und Summen', function (): void {
URL::resolveMissingNamedRoutesUsing(fn (string $name, array $parameters) => '/'.$name.'/'.implode('/', $parameters));
$root = makeUser();
$anna = makeUser($root->id);
makeAbo($anna->id, $anna->id, 'me', '2026-06-05', 50);
makeAbo($anna->id, $anna->id, 'ot', '2026-06-10', 20);
$this->actingAs($root);
$service = app(TeamAboOverviewService::class);
$overview = $service->linesOverview($root, 6, 2026);
$growth = $service->monthlyGrowth($root, 2026);
$html = view('user.team.abos', [
'currentMonthName' => HTMLHelper::getTransMonths()[6],
'overview' => $overview,
'growth' => $growth,
'growthChart' => [
'labels' => array_map(fn ($r) => $r['label'], $growth['months']),
'own' => array_map(fn ($r) => $r['own_new'], $growth['months']),
'customer' => array_map(fn ($r) => $r['customer_new'], $growth['months']),
'step' => 1,
'suggested_max' => 5,
],
'growthYear' => 2026,
'filterYears' => HTMLHelper::getYearRange(2022),
])->render();
expect($html)
->toContain(__('abo.team_by_level'))
->toContain(__('abo.team_total_abos'))
->toContain(__('abo.team_growth'))
->toContain('aboGrowthChart')
->toContain($anna->email);
});
it('berechnet das monatliche Abo-Wachstum je Jahr', function (): void {
$root = makeUser();
$anna = makeUser($root->id);
$lena = makeUser($root->id);
// Juni 2026: 1 Eigen-Abo + 2 Kundenabos
makeAbo($anna->id, $anna->id, 'me', '2026-06-05');
makeAbo($anna->id, $anna->id, 'ot', '2026-06-10');
makeAbo($lena->id, $lena->id, 'ot', '2026-06-20');
// März 2026: 1 Kundenabo
makeAbo($anna->id, $anna->id, 'ot', '2026-03-15');
// Anderes Jahr: zählt nicht
makeAbo($anna->id, $anna->id, 'ot', '2025-06-01');
$growth = app(TeamAboOverviewService::class)->monthlyGrowth($root, 2026);
expect($growth['year'])->toBe(2026)
->and($growth['months'])->toHaveCount(12);
$june = collect($growth['months'])->firstWhere('month', 6);
$march = collect($growth['months'])->firstWhere('month', 3);
expect($june['own_new'])->toBe(1)
->and($june['customer_new'])->toBe(2)
->and($june['total_new'])->toBe(3)
->and($march['customer_new'])->toBe(1)
->and($march['own_new'])->toBe(0);
expect($growth['totals']['own_new'])->toBe(1)
->and($growth['totals']['customer_new'])->toBe(3)
->and($growth['totals']['total_new'])->toBe(4);
});
it('liefert leichtgewichtige Dashboard-KPIs inkl. 1000-Punkte-Shop', function (): void {
$root = makeUser();
$anna = makeUser($root->id);
$lena = makeUser($root->id);
// Abos
makeAbo($anna->id, $anna->id, 'me', '2026-06-05'); // 1 Eigen-Abo
makeAbo($anna->id, $anna->id, 'ot', '2026-06-10'); // 1 Kundenabo
makeAbo($lena->id, $lena->id, 'ot', '2026-06-12'); // 1 Kundenabo
// Anna erreicht 1000 Punkte Kundenumsatz im Juni 2026
UserSalesVolume::create([
'user_id' => $anna->id,
'month' => 6,
'year' => 2026,
'month_KP_points' => 600,
'month_shop_points' => 500,
'points' => 1100,
'status' => 1,
]);
$kpis = app(TeamAboOverviewService::class)->teamKpis($root, 6, 2026);
expect($kpis['team_partner_abos'])->toBe(1)
->and($kpis['team_customer_abos'])->toBe(2)
->and($kpis['team_total_abos'])->toBe(3)
->and($kpis['shop_1000'])->toBe(1);
});
it('liefert die Abo-Zusammenfassung eines Team-Mitglieds inkl. Ausführungen', function (): void {
$root = makeUser();
$anna = makeUser($root->id);
$ownAbo = makeAbo($anna->id, $anna->id, 'me', '2026-06-05', 50);
makeAbo($anna->id, $anna->id, 'ot', '2026-06-10', 20);
makeAbo($anna->id, $anna->id, 'ot', '2026-05-01', 30);
// Zwei ausgeführte (bezahlte) Lieferungen für das Eigen-Abo
UserAboOrder::create(['user_abo_id' => $ownAbo->id, 'shopping_order_id' => 0, 'status' => 2, 'paid' => true]);
UserAboOrder::create(['user_abo_id' => $ownAbo->id, 'shopping_order_id' => 0, 'status' => 2, 'paid' => true]);
$summary = app(TeamAboOverviewService::class)->memberAboSummary($root, $anna->id);
expect($summary)->not->toBeNull()
->and($summary['email'])->toBe($anna->email)
->and($summary['own_abo'])->not->toBeNull()
->and($summary['own_abo']['points'])->toBe(50.0)
->and($summary['own_abo']['executions'])->toBe(2)
->and($summary['customer_abos']['count'])->toBe(2)
->and($summary['customer_abos']['total_points'])->toBe(50.0)
->and($summary['customer_abos']['items'])->toHaveCount(2);
});
it('liefert null, wenn das Mitglied nicht im Team liegt', function (): void {
$root = makeUser();
$stranger = makeUser(); // kein Sponsor → nicht in der Downline
$summary = app(TeamAboOverviewService::class)->memberAboSummary($root, $stranger->id);
expect($summary)->toBeNull();
});