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>
503 lines
19 KiB
PHP
503 lines
19 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 ein systemseitig reaktiviertes Abo wieder zum Bestand (A16)', function (): void {
|
|
Carbon\Carbon::setTestNow('2026-09-15');
|
|
|
|
$root = makeUser();
|
|
$anna = makeUser($root->id);
|
|
|
|
// Ende Juli deaktiviert, Anfang August durch einen Systemschreibpfad
|
|
// (Recovery-Zahlung/Retry/externe Erledigung) wieder aktiviert. Ohne
|
|
// protokollierte Reaktivierung fiele das Abo ab August dauerhaft aus dem Bestand.
|
|
$abo = makeAbo($anna->id, $anna->id, 'ot', '2026-06-01');
|
|
$abo->update(['status' => 6, 'active' => false]);
|
|
$abo->status_histories()->latest('id')->first()->forceFill(['created_at' => '2026-07-28'])->save();
|
|
|
|
$abo->update(['status' => 2, 'active' => true]);
|
|
$abo->status_histories()->latest('id')->first()->forceFill(['created_at' => '2026-08-03'])->save();
|
|
|
|
$svc = app(TeamAboOverviewService::class);
|
|
|
|
expect($svc->teamKpis($root, 7, 2026)['team_customer_abos'])->toBe(0)
|
|
->and($svc->teamKpis($root, 8, 2026)['team_customer_abos'])->toBe(1);
|
|
|
|
Carbon\Carbon::setTestNow();
|
|
});
|
|
|
|
it('wertet eine Reaktivierung in derselben Sekunde als spaeteres Ereignis (A16)', function (): void {
|
|
Carbon\Carbon::setTestNow('2026-09-15');
|
|
|
|
$root = makeUser();
|
|
$anna = makeUser($root->id);
|
|
|
|
// created_at ist sekundengenau: Deaktivierung und Reaktivierung koennen im
|
|
// selben Request denselben Zeitstempel tragen. Dann entscheidet die Reihenfolge.
|
|
$abo = makeAbo($anna->id, $anna->id, 'ot', '2026-06-01');
|
|
$abo->update(['status' => 6, 'active' => false]);
|
|
$abo->update(['status' => 2, 'active' => true]);
|
|
|
|
$abo->status_histories()->update(['created_at' => '2026-07-20 09:00:00']);
|
|
|
|
$svc = app(TeamAboOverviewService::class);
|
|
|
|
expect($abo->status_histories()->count())->toBe(2)
|
|
->and($svc->teamKpis($root, 7, 2026)['team_customer_abos'])->toBe(1);
|
|
|
|
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();
|
|
});
|
|
|
|
it('prognostiziert am letzten Liefertag noch die offenen Ausfuehrungen (A13)', function (): void {
|
|
// 25.07., morgens: der Ausfuehrungsjob laeuft um 04:00 — vorher steht die
|
|
// Lieferung noch aus und gehoert in die Prognose, nicht auf null.
|
|
Carbon\Carbon::setTestNow('2026-07-25 01:00:00');
|
|
|
|
$root = makeUser();
|
|
$anna = makeUser($root->id);
|
|
|
|
$abo = makeAbo($anna->id, $anna->id, 'ot', '2026-05-01', 40);
|
|
$abo->update(['next_date' => '2026-07-25']);
|
|
|
|
$expectedAt25 = app(TeamAboOverviewService::class)->linesOverview($root, 7, 2026)['totals']['abo_points_expected'];
|
|
|
|
// Einen Tag spaeter ist das Fenster zu, die Prognose faellt auf das Ist zurueck.
|
|
Carbon\Carbon::setTestNow('2026-07-26 01:00:00');
|
|
$overview = app(TeamAboOverviewService::class)->linesOverview($root, 7, 2026);
|
|
$billedAt26 = $overview['totals']['abo_points_billed'];
|
|
|
|
expect($expectedAt25)->toBeGreaterThan($billedAt26)
|
|
->and($overview['totals']['abo_points_expected'])->toBe($billedAt26);
|
|
|
|
Carbon\Carbon::setTestNow();
|
|
});
|