Statistik-Änderungen 2026-08-19 (dev/2026-07-22/entwicklungsplan-2026-08-19.md, alle 8 Pakete gemäß beantworteten F-Fragen): - N1 Monatsstatistik: Team-Cluster (Firstlines klickbar -> neue Seite user_team_firstlines, Teampartner gesamt/Payline [nur aktive], Abos Payline mit +N) + Level-Kachel (Quali-Stufe, fehlende Payline-/persönliche Punkte, Karriere-Level); LiveCommissionService-Snapshot v3 reicht Level-/Quali-Daten durch - N2 Abo-Kacheln auf Home monatsbezogen (aboStockCount-Rückrechnung über cancel/deleted/Status-Historie; VIP-Statistik bleibt tagesaktuell) - N3 VIP-Statistik 'Recruiter des Monats' (Neupartner-Definition wie KPI, rekursives Ketten-Volumen als Gesamtpunkte inkl. Starterpaket) + Drilldown/CSV-Spalte 'Punkte gesamt' - N4 Prognose-Block 'Level & Provision laut Prognose (Stand 25.)' mit unverbindlich-Kennzeichnung (ebenengenaue offene Abo-Punkte + TP-Laufstände) - N5 Checkout-Herkunft: Dropdown raus, Pflicht-Freitext (lokalisierte Fehlermeldung) - N6 Berater-Profilfoto (user_accounts.profile_image, Einwilligung als Sprachkey-Platzhalter, DSGVO-Löschung) + teaminterne Top-3-Karte (TopResultsService, Scope-Schalter) - N7 USt-Optionen: 'nicht umsatzsteuerpflichtig' zuerst + '§ 19 UStG'-Textfix - N8 Jahresgebühr-Nachfassliste je Berater (AnnualFeeDueService, überfällig/1-3 Monate, Sidenav) Kontroll-Review (8 Perspektiven) angewendet: next-Level-Fallback im Steady-State (resolveNextLevel — sonst 'Höchstes Level erreicht' für Normalfälle), _calculated-Punktebasis bei mehr Paylines, keine Wiederbelebung deaktivierter Abos in der Monatsrückrechnung, Payline-Abo-Kachel monatskonsistent, Top-3-Nachrücker; Performance: BackofficeDashboardService als Singleton mit lineBuckets-Memo (+flushLineBucketsMemo), level_forecast 6h-Cache; Reuse: User-Scopes firstlinesOf/newPartnerIn, SalesVolumePoints::totalPointsByUser, Slim::storeCroppedImage (3 Upload-Kopien vereinigt), activeAbo-Scope statt Inline-Prädikate, Form::open-Konvention, Return-Types Repair-Härtung (Kundenentscheide): VP-Nachtrepair dedupliziert über shopping_order_id (Live-Pfad schreibt sie jetzt mit), zählende Alt-Zeilen-Erkennung, Vormonats-Scan für Monatswechsel, Firmenaccount-Buchungen (config mivita.company_user_id) werden automatisch auf den Berater umgebucht (in place bzw. per Gegenbuchung bei geschlossenem Monat; Echtfall Anna Schall Order 58797 -> Christina Schuder), Berater-zu-Berater nur Warnung; Incentive-Repair: m_sponsor=NULL löscht nicht mehr, FK-Repair vor Stale-Bereinigung, gebatchte Lookups, zentrales Incentive::qualificationWindow(); .env aus dem Git-Tracking entfernt (Pflege per SFTP) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
401 lines
16 KiB
PHP
401 lines
16 KiB
PHP
<?php
|
|
|
|
use App\Http\Controllers\User\BackofficeStatisticsController;
|
|
use App\Models\ShoppingOrder;
|
|
use App\Services\Backoffice\BackofficeDashboardService;
|
|
use App\Services\Backoffice\BackofficeDrilldownService;
|
|
use App\Services\Backoffice\PersonalStatisticsService;
|
|
use App\Services\BusinessPlan\LiveCommissionService;
|
|
use App\User;
|
|
use Illuminate\Http\Request;
|
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
use Tests\TestCase;
|
|
|
|
uses(TestCase::class);
|
|
|
|
beforeEach(function () {
|
|
// Der Controller holt die Live-Provision über den Container; ohne Datenbank-
|
|
// Schema (user_businesses) hier durch einen Fake mit Null-Werten ersetzen.
|
|
app()->instance(LiveCommissionService::class, new class extends LiveCommissionService
|
|
{
|
|
public function commission(User $user, int $month, int $year): array
|
|
{
|
|
return ['total' => 0.0, 'pp' => 0.0, 'shop' => 0.0, 'growth' => 0.0, 'is_live' => true];
|
|
}
|
|
});
|
|
});
|
|
|
|
function makeBackofficeStatisticsUser(int $admin): User
|
|
{
|
|
return (new User)->forceFill([
|
|
'admin' => $admin,
|
|
'lang' => 'de',
|
|
'active' => 1,
|
|
'blocked' => 0,
|
|
'wizard' => 100,
|
|
]);
|
|
}
|
|
|
|
function makeBackofficeStatisticsRequest(User $user): Request
|
|
{
|
|
$request = Request::create('/user/backoffice/statistics', 'GET');
|
|
$request->setUserResolver(fn () => $user);
|
|
|
|
return $request;
|
|
}
|
|
|
|
function makeBackofficeStatisticsController(?BackofficeDashboardService $dashboardService = null, ?BackofficeDrilldownService $drilldownService = null, ?PersonalStatisticsService $personalService = null): BackofficeStatisticsController
|
|
{
|
|
$dashboardService ??= Mockery::mock(BackofficeDashboardService::class);
|
|
$drilldownService ??= Mockery::mock(BackofficeDrilldownService::class);
|
|
$personalService ??= Mockery::mock(PersonalStatisticsService::class);
|
|
|
|
return new BackofficeStatisticsController($dashboardService, $drilldownService, $personalService);
|
|
}
|
|
|
|
function backofficeStatisticsStreamedContent(StreamedResponse $response): string
|
|
{
|
|
ob_start();
|
|
$response->sendContent();
|
|
|
|
return (string) ob_get_clean();
|
|
}
|
|
|
|
it('zeigt die Backoffice-Statistik fuer VIP-User', function () {
|
|
$vip = makeBackofficeStatisticsUser(1);
|
|
$dashboardService = Mockery::mock(BackofficeDashboardService::class);
|
|
$dashboardService
|
|
->shouldReceive('overview')
|
|
->once()
|
|
->andReturn([
|
|
'month' => now()->month,
|
|
'year' => now()->year,
|
|
'metric_labels' => [],
|
|
'lines' => [],
|
|
'totals' => [],
|
|
'_meta' => [
|
|
'source_label' => 'Live',
|
|
'calculated_at' => null,
|
|
],
|
|
]);
|
|
|
|
$controller = makeBackofficeStatisticsController($dashboardService);
|
|
|
|
$response = $controller->index(makeBackofficeStatisticsRequest($vip));
|
|
|
|
expect($response->getName())->toBe('user.backoffice.statistics.index');
|
|
expect($response->getData())->toHaveKeys(['selectedMonth', 'selectedYear', 'statistics', 'performance']);
|
|
expect($response->getData()['performance']['source_label'])->toBe('Live');
|
|
});
|
|
|
|
it('behaelt den zuletzt gewaehlten Statistik-Zeitraum in der Session', function () {
|
|
$vip = makeBackofficeStatisticsUser(1);
|
|
$dashboardService = Mockery::mock(BackofficeDashboardService::class);
|
|
$dashboardService
|
|
->shouldReceive('overview')
|
|
->twice()
|
|
->andReturn([
|
|
'month' => 4,
|
|
'year' => 2026,
|
|
'metric_labels' => [],
|
|
'lines' => [],
|
|
'totals' => [],
|
|
'_meta' => [
|
|
'source_label' => 'Snapshot',
|
|
'calculated_at' => '17.05.2026 04:45',
|
|
],
|
|
]);
|
|
|
|
$controller = makeBackofficeStatisticsController($dashboardService);
|
|
$firstRequest = makeBackofficeStatisticsRequest($vip);
|
|
$firstRequest->query->set('month', 4);
|
|
$firstRequest->query->set('year', 2026);
|
|
|
|
$controller->index($firstRequest);
|
|
$response = $controller->index(makeBackofficeStatisticsRequest($vip));
|
|
|
|
expect($response->getData()['selectedMonth'])->toBe(4);
|
|
expect($response->getData()['selectedYear'])->toBe(2026);
|
|
expect($response->getData()['performance']['source_label'])->toBe('Snapshot');
|
|
});
|
|
|
|
it('blockiert die Backoffice-Statistik fuer normale aktive User', function () {
|
|
$user = makeBackofficeStatisticsUser(0);
|
|
$controller = makeBackofficeStatisticsController();
|
|
|
|
expect(fn () => $controller->index(makeBackofficeStatisticsRequest($user)))
|
|
->toThrow(NotFoundHttpException::class);
|
|
});
|
|
|
|
it('blockiert die persoenliche Statistik fuer normale aktive User', function () {
|
|
$user = makeBackofficeStatisticsUser(0);
|
|
$controller = makeBackofficeStatisticsController();
|
|
|
|
expect(fn () => $controller->my(makeBackofficeStatisticsRequest($user)))
|
|
->toThrow(NotFoundHttpException::class);
|
|
});
|
|
|
|
it('liefert die persoenliche Statistik fuer VIP-User', function () {
|
|
$vip = makeBackofficeStatisticsUser(1);
|
|
$personalService = Mockery::mock(PersonalStatisticsService::class);
|
|
$personalService
|
|
->shouldReceive('monthly')
|
|
->once()
|
|
->andReturn(['month' => 5, 'year' => 2026, 'totals' => [], 'groups' => []]);
|
|
|
|
$controller = makeBackofficeStatisticsController(null, null, $personalService);
|
|
$request = makeBackofficeStatisticsRequest($vip);
|
|
$request->query->set('month', 5);
|
|
$request->query->set('year', 2026);
|
|
|
|
$response = $controller->my($request);
|
|
|
|
expect($response->name())->toBe('user.backoffice.statistics.my');
|
|
expect($response->getData()['selectedMonth'])->toBe(5);
|
|
expect($response->getData()['statistics']['year'])->toBe(2026);
|
|
});
|
|
|
|
it('erstellt einen CSV-Export fuer die Statistik-Uebersicht', function () {
|
|
$vip = makeBackofficeStatisticsUser(1);
|
|
$dashboardService = Mockery::mock(BackofficeDashboardService::class);
|
|
$dashboardService
|
|
->shouldReceive('overview')
|
|
->once()
|
|
->andReturn([
|
|
'month' => 5,
|
|
'year' => 2026,
|
|
'metric_labels' => [],
|
|
'lines' => [
|
|
[
|
|
'label' => 'Linie 1',
|
|
'consultants' => 2,
|
|
'new_partners' => 1,
|
|
'team_partner_abos' => 1,
|
|
'team_partner_abos_new' => 1,
|
|
'team_customer_abos' => 1,
|
|
'team_customer_abos_new' => 1,
|
|
'own_points' => 400,
|
|
'external_points' => 700,
|
|
'customer_abo_points' => 700,
|
|
'customer_single_order_points' => 0,
|
|
'customer_other_points' => 0,
|
|
'total_points' => 1100,
|
|
'shop_1000' => 1,
|
|
'turnover_net' => 300,
|
|
],
|
|
],
|
|
'totals' => [
|
|
'label' => 'Summe',
|
|
'consultants' => 2,
|
|
'new_partners' => 1,
|
|
'team_partner_abos' => 1,
|
|
'team_partner_abos_new' => 1,
|
|
'team_customer_abos' => 1,
|
|
'team_customer_abos_new' => 1,
|
|
'own_points' => 400,
|
|
'external_points' => 700,
|
|
'customer_abo_points' => 700,
|
|
'customer_single_order_points' => 0,
|
|
'customer_other_points' => 0,
|
|
'total_points' => 1100,
|
|
'shop_1000' => 1,
|
|
'turnover_net' => 300,
|
|
],
|
|
]);
|
|
|
|
$controller = makeBackofficeStatisticsController($dashboardService);
|
|
$request = makeBackofficeStatisticsRequest($vip);
|
|
$request->query->set('month', 5);
|
|
$request->query->set('year', 2026);
|
|
|
|
$response = $controller->overviewExport($request);
|
|
$content = backofficeStatisticsStreamedContent($response);
|
|
|
|
expect($response)->toBeInstanceOf(StreamedResponse::class);
|
|
expect($response->headers->get('content-disposition'))->toContain('backoffice-statistik-uebersicht-05-2026.csv');
|
|
expect($content)->toContain('Linie;Berater;Neupartner;Teamabos');
|
|
expect($content)->toContain('Neue Teamabos');
|
|
expect($content)->toContain('"Linie 1";2;1;1;1;1;1;400;700;700;0;0;1100;1;300');
|
|
expect($content)->toContain('Summe;2;1;1;1;1;1;400;700;700;0;0;1100;1;300');
|
|
});
|
|
|
|
it('erstellt einen CSV-Export fuer Detaildaten mit Karriere-Level und Summenzeile', function () {
|
|
$vip = makeBackofficeStatisticsUser(1);
|
|
$drilldownService = Mockery::mock(BackofficeDrilldownService::class);
|
|
$drilldownService
|
|
->shouldReceive('details')
|
|
->once()
|
|
->with($vip, 0, 'shop_1000', 5, 2026, false)
|
|
->andReturn([
|
|
'metric' => 'shop_1000',
|
|
'metric_label' => '1000 Punkte Shop',
|
|
'only_new' => false,
|
|
'line' => 0,
|
|
'line_label' => 'Alle Linien',
|
|
'month' => 5,
|
|
'year' => 2026,
|
|
'rows' => [
|
|
[
|
|
'name' => 'Max Mustermann',
|
|
'email' => 'max@example.test',
|
|
'career_level' => 'Partner',
|
|
'own_points' => 400,
|
|
'external_points' => 700,
|
|
'customer_abo_points' => 700,
|
|
'customer_single_order_points' => 0,
|
|
'customer_other_points' => 0,
|
|
'total_points' => 1100,
|
|
],
|
|
],
|
|
'summary' => [
|
|
'count' => 1,
|
|
'points' => 0,
|
|
'own_points' => 400,
|
|
'external_points' => 700,
|
|
'customer_abo_points' => 700,
|
|
'customer_single_order_points' => 0,
|
|
'customer_other_points' => 0,
|
|
'total_points' => 1100,
|
|
'deliveries' => 0,
|
|
],
|
|
]);
|
|
|
|
$controller = makeBackofficeStatisticsController(null, $drilldownService);
|
|
$request = makeBackofficeStatisticsRequest($vip);
|
|
$request->query->set('line', 0);
|
|
$request->query->set('metric', 'shop_1000');
|
|
$request->query->set('month', 5);
|
|
$request->query->set('year', 2026);
|
|
|
|
$response = $controller->export($request);
|
|
$content = backofficeStatisticsStreamedContent($response);
|
|
|
|
expect($response)->toBeInstanceOf(StreamedResponse::class);
|
|
expect($response->headers->get('content-disposition'))->toContain('backoffice-statistik-shop_1000-linie-alle-05-2026.csv');
|
|
expect($content)->toContain('Name;E-Mail;Karriere-Level;Eigenpunkte;Shop-Punkte;Abo-Punkte;"Sonstige Punkte";Kundenpunkte;Gesamtpunkte');
|
|
expect($content)->toContain('"Max Mustermann";max@example.test;Partner;400;0;700;0;700;1100');
|
|
expect($content)->toContain('Summe;"1 Eintraege";;400;0;700;0;700;1100');
|
|
});
|
|
|
|
it('nutzt den gespeicherten Zeitraum fuer Detailansicht und Export', function () {
|
|
$vip = makeBackofficeStatisticsUser(1);
|
|
$dashboardService = Mockery::mock(BackofficeDashboardService::class);
|
|
$drilldownService = Mockery::mock(BackofficeDrilldownService::class);
|
|
|
|
$dashboardService
|
|
->shouldReceive('overview')
|
|
->once()
|
|
->andReturn([
|
|
'month' => 4,
|
|
'year' => 2026,
|
|
'metric_labels' => [],
|
|
'lines' => [],
|
|
'totals' => [],
|
|
]);
|
|
|
|
$drilldownService
|
|
->shouldReceive('details')
|
|
->once()
|
|
->with($vip, 1, 'consultants', 4, 2026, false)
|
|
->andReturn([
|
|
'metric' => 'consultants',
|
|
'metric_label' => 'Berater',
|
|
'only_new' => false,
|
|
'line' => 1,
|
|
'line_label' => 'Linie 1',
|
|
'month' => 4,
|
|
'year' => 2026,
|
|
'rows' => [],
|
|
'summary' => ['count' => 0],
|
|
]);
|
|
|
|
$drilldownService
|
|
->shouldReceive('details')
|
|
->once()
|
|
->with($vip, 1, 'consultants', 4, 2026, false)
|
|
->andReturn([
|
|
'metric' => 'consultants',
|
|
'metric_label' => 'Berater',
|
|
'only_new' => false,
|
|
'line' => 1,
|
|
'line_label' => 'Linie 1',
|
|
'month' => 4,
|
|
'year' => 2026,
|
|
'rows' => [],
|
|
'summary' => ['count' => 0],
|
|
]);
|
|
|
|
$controller = makeBackofficeStatisticsController($dashboardService, $drilldownService);
|
|
$indexRequest = makeBackofficeStatisticsRequest($vip);
|
|
$indexRequest->query->set('month', 4);
|
|
$indexRequest->query->set('year', 2026);
|
|
$controller->index($indexRequest);
|
|
|
|
$detailsRequest = makeBackofficeStatisticsRequest($vip);
|
|
$detailsRequest->query->set('line', 1);
|
|
$detailsRequest->query->set('metric', 'consultants');
|
|
$detailsResponse = $controller->details($detailsRequest);
|
|
|
|
$exportRequest = makeBackofficeStatisticsRequest($vip);
|
|
$exportRequest->query->set('line', 1);
|
|
$exportRequest->query->set('metric', 'consultants');
|
|
$exportResponse = $controller->export($exportRequest);
|
|
|
|
expect($detailsResponse->getData()['selectedMonth'])->toBe(4);
|
|
expect($detailsResponse->getData()['selectedYear'])->toBe(2026);
|
|
expect($exportResponse->headers->get('content-disposition'))->toContain('backoffice-statistik-consultants-linie-1-04-2026.csv');
|
|
});
|
|
|
|
it('rendert eine Suche in der Detailtabelle', function () {
|
|
$html = file_get_contents(resource_path('views/user/backoffice/statistics/details.blade.php'));
|
|
|
|
expect($html)->toContain('backoffice-statistics-detail-search');
|
|
expect($html)->toContain('backoffice-statistics-detail-table');
|
|
expect($html)->toContain('data-sortable="true"');
|
|
expect($html)->toContain('getSortValue');
|
|
expect($html)->toContain('user_backoffice_statistics_export');
|
|
expect($html)->toContain('Datenschutz-Hinweis');
|
|
expect($html)->toContain('Karriere-Level');
|
|
expect($html)->not->toContain('Qualifikation');
|
|
|
|
$indexHtml = file_get_contents(resource_path('views/user/backoffice/statistics/index.blade.php'));
|
|
|
|
expect($indexHtml)->toContain('user_backoffice_statistics_overview_export');
|
|
expect($indexHtml)->toContain('Datenquelle:');
|
|
expect($indexHtml)->toContain('Berechnet in');
|
|
|
|
// Vereinfachte Tabelle: nur noch Berater/Umsatz/Teamabos/Teamkundenabos
|
|
expect($indexHtml)->toContain("'total_points'");
|
|
expect($indexHtml)->toContain('Umsatz (Punkte)');
|
|
expect($indexHtml)->not->toContain('Eigenpunkte');
|
|
expect($indexHtml)->not->toContain('Einzelbestellungs-Punkte');
|
|
|
|
// Neupartner & 1000-Punkte-Shop als klickbare KPI-Cards
|
|
expect($indexHtml)->toContain("'metric' => 'new_partners'");
|
|
expect($indexHtml)->toContain("'metric' => 'shop_1000'");
|
|
});
|
|
|
|
it('erfasst die Herkunft bei Kundenbestellungen im Shop-Checkout', function () {
|
|
$checkoutView = file_get_contents(resource_path('views/web/templates/checkout.blade.php'));
|
|
$checkoutController = file_get_contents(app_path('Http/Controllers/Web/CheckoutController.php'));
|
|
$checkoutRepository = file_get_contents(app_path('Repositories/CheckoutRepository.php'));
|
|
$orderDetail = file_get_contents(resource_path('views/admin/sales/_detail.blade.php'));
|
|
|
|
// Kundenentscheid F6: nur noch Freitext-Pflichtfeld, kein Dropdown mehr
|
|
expect($checkoutView)->toContain('customer_order_source_comment');
|
|
expect($checkoutView)->toContain('Wie bist du auf uns aufmerksam geworden?');
|
|
expect($checkoutView)->not->toContain('name="customer_order_source"');
|
|
expect($checkoutController)->toContain("Request::get('is_from') === 'shopping'");
|
|
expect($checkoutController)->toContain("\$rules['customer_order_source_comment'] = 'required|string|max:500'");
|
|
expect($checkoutRepository)->toContain('$shopping_user->is_from === \'shopping\'');
|
|
expect($checkoutRepository)->toContain('customer_order_source_comment');
|
|
expect($orderDetail)->toContain('getCustomerOrderSourceLabel');
|
|
expect($orderDetail)->toContain('customer_order_source_comment');
|
|
|
|
$shoppingOrder = (new ShoppingOrder)->forceFill([
|
|
'customer_order_source' => 'social_media',
|
|
]);
|
|
|
|
expect($shoppingOrder->getCustomerOrderSourceLabel())->toBe('Social Media');
|
|
});
|