From 5a38f4ac3e93ed94a4b23322dd6fd65174fdc8d5 Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 10 Sep 2026 11:19:38 +0000 Subject: [PATCH] Statistik: uebrige Audit-Korrekturen (Abo-Regeln, Prognose, Sortierung) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/Http/Controllers/User/AboController.php | 18 +- .../User/BackofficeStatisticsController.php | 41 ++- app/Models/UserAbo.php | 97 +++++++ app/Repositories/AboRepository.php | 31 ++- app/Services/Abo/PaylineForecastService.php | 199 +++++++++++--- app/Services/Abo/TeamAboOverviewService.php | 44 +++- app/Services/AboHelper.php | 4 + .../BusinessPlan/LiveCommissionService.php | 25 +- app/Services/Team/AnnualFeeDueService.php | 12 +- app/Services/Team/TopResultsService.php | 89 +++++-- resources/lang/de/abo.php | 8 +- resources/lang/de/home.php | 7 + resources/lang/en/abo.php | 8 +- resources/lang/en/home.php | 7 + resources/lang/es/abo.php | 8 +- resources/lang/es/home.php | 7 + resources/lang/fr/abo.php | 8 +- resources/lang/fr/home.php | 7 + .../components/_sortable_table.blade.php | 143 ++++++++++ .../views/dashboard/_statistics.blade.php | 90 +++++-- .../user/abo/_customer_abo_list.blade.php | 110 ++++++++ .../backoffice/statistics/details.blade.php | 148 ++--------- tests/Feature/AboLockDaysSettingTest.php | 15 +- tests/Feature/AboStatusGuardTest.php | 246 ++++++++++++++++++ .../BackofficeStatisticsDrilldownTest.php | 65 +++++ tests/Feature/CustomerAboListViewTest.php | 137 ++++++++++ tests/Feature/HomeMonthlyStatsTest.php | 124 ++++++++- tests/Feature/LiveCommissionServiceTest.php | 78 ++++++ tests/Feature/PaylineForecastServiceTest.php | 244 ++++++++++++++++- tests/Feature/TeamAboOverviewTest.php | 70 +++++ tests/Feature/TopResultsServiceTest.php | 82 ++++++ 31 files changed, 1931 insertions(+), 241 deletions(-) create mode 100644 resources/views/components/_sortable_table.blade.php create mode 100644 resources/views/user/abo/_customer_abo_list.blade.php create mode 100644 tests/Feature/AboStatusGuardTest.php create mode 100644 tests/Feature/CustomerAboListViewTest.php diff --git a/app/Http/Controllers/User/AboController.php b/app/Http/Controllers/User/AboController.php index 04c76d5c..e6719c10 100644 --- a/app/Http/Controllers/User/AboController.php +++ b/app/Http/Controllers/User/AboController.php @@ -13,6 +13,7 @@ use App\Models\UserAbo; use App\Models\UserAboItem; use App\Models\UserAboOneTimeItem; use App\Repositories\AboRepository; +use App\Services\Abo\PaylineForecastService; use App\Services\AboHelper; use App\Services\AboItemHistoryService; use App\Services\AboOneTimeService; @@ -30,6 +31,16 @@ use Yard; class AboController extends Controller { + /** + * Relationen, die die Kundenabo-Liste je Zeile auswertet — inklusive + * `user_abo_orders` fuer die Anzahl bezahlter Lieferungen + * ({@see UserAbo::getCountPaidOrders()}). Ohne Vorladen entsteht dort eine + * Abfrage pro dargestelltem Abo. + * + * @var string[] + */ + private const CUSTOMER_LIST_RELATIONS = ['user_abo_items', 'user_abo_items.product', 'shopping_user', 'user_abo_orders']; + protected $aboRepository; public function __construct(AboRepository $aboRepository) @@ -75,7 +86,7 @@ class AboController extends Controller ->where('is_for', 'ot'); $user_abos = (clone $baseQuery) - ->with(['user_abo_items', 'user_abo_items.product', 'shopping_user']) + ->with(self::CUSTOMER_LIST_RELATIONS) ->addedInMonth($selectedYear, $selectedMonth) ->orderBy('id', 'desc') ->get(); @@ -85,6 +96,11 @@ class AboController extends Controller 'abo_payment_links' => $this->getAboPaymentLinks(), 'view' => 'ot', 'isAdmin' => false, + // Liefertag-Vorschau nur aus den eigenen Kundenabos + // (Kundenwunsch 2026-09-07), und nur im laufenden Monat sinnvoll. + 'customerAboForecast' => $selectedMonth === 0 || ($selectedMonth === (int) now()->month && $selectedYear === (int) now()->year) + ? app(PaylineForecastService::class)->personalCustomerAboForecast(\Auth::user()) + : null, 'chartData' => AboHelper::getMonthlyAboCounts($baseQuery, $selectedYear, 'ot', \Auth::user()->id), 'chartYear' => $selectedYear, 'chartYears' => HTMLHelper::getYearRange(2026), diff --git a/app/Http/Controllers/User/BackofficeStatisticsController.php b/app/Http/Controllers/User/BackofficeStatisticsController.php index 9e3d28bc..c3b63d5d 100644 --- a/app/Http/Controllers/User/BackofficeStatisticsController.php +++ b/app/Http/Controllers/User/BackofficeStatisticsController.php @@ -37,6 +37,7 @@ class BackofficeStatisticsController extends Controller $statistics = $this->dashboardService->overview($request->user(), $selectedMonth, $selectedYear); $performance = [ 'duration_ms' => round((microtime(true) - $startTime) * 1000, 2), + 'source' => $statistics['_meta']['source'] ?? 'live', 'source_label' => $statistics['_meta']['source_label'] ?? 'Live', 'calculated_at' => $statistics['_meta']['calculated_at'] ?? null, ]; @@ -114,11 +115,11 @@ class BackofficeStatisticsController extends Controller fputcsv($output, $this->csvHeaders($details['metric']), ';'); foreach ($details['rows'] as $row) { - fputcsv($output, $this->csvRow($details['metric'], $row), ';'); + fputcsv($output, self::csvSafeRow($this->csvRow($details['metric'], $row)), ';'); } fputcsv($output, []); - fputcsv($output, $this->csvSummaryRow($details), ';'); + fputcsv($output, self::csvSafeRow($this->csvSummaryRow($details)), ';'); fclose($output); }, $filename, [ @@ -143,11 +144,11 @@ class BackofficeStatisticsController extends Controller fputcsv($output, $this->overviewCsvHeaders(), ';'); foreach ($statistics['lines'] as $line) { - fputcsv($output, $this->overviewCsvRow($line), ';'); + fputcsv($output, self::csvSafeRow($this->overviewCsvRow($line)), ';'); } fputcsv($output, []); - fputcsv($output, $this->overviewCsvRow($statistics['totals']), ';'); + fputcsv($output, self::csvSafeRow($this->overviewCsvRow($statistics['totals'])), ';'); fclose($output); }, $filename, [ @@ -220,6 +221,30 @@ class BackofficeStatisticsController extends Controller ]; } + /** + * Textzellen fuer Tabellenprogramme als Text kennzeichnen. + * + * `fputcsv()` schuetzt die Dateistruktur, nicht die Interpretation: Ein Feld, das + * mit `=`, `+`, `-` oder `@` beginnt, wird von Excel und LibreOffice als Formel + * gelesen. Die Werte stammen aus Namen, Kontaktdaten und Freitexten anderer + * Personen — also nicht aus der Hand dessen, der den Export oeffnet. + * + * Zahlen bleiben unangetastet, damit die Spalten rechenbar bleiben. + * + * @param array $row + * @return array + */ + private static function csvSafeRow(array $row): array + { + return array_map(static function ($value) { + if (! is_string($value) || $value === '') { + return $value; + } + + return str_contains("=+-@\t\r", $value[0]) ? "'".$value : $value; + }, $row); + } + /** * @return string[] */ @@ -238,7 +263,7 @@ class BackofficeStatisticsController extends Controller } if ($metric === 'new_partners') { - return ['Name', 'E-Mail', 'Karriere-Level', 'Sponsor', 'Kundenpunkte', 'Punkte gesamt', 'Aktiv seit', 'Account gueltig bis']; + return ['Name', 'E-Mail', 'Telefon', 'Karriere-Level', 'Sponsor', 'Punkte seiner Kunden', 'Gesamt inkl. Eigenbestellung', 'Aktiv seit', 'Account gueltig bis']; } return ['Name', 'E-Mail', 'Karriere-Level', 'Aktiv seit', 'Account gueltig bis', 'Account Status']; @@ -293,6 +318,7 @@ class BackofficeStatisticsController extends Controller return [ $row['name'] ?? '', $row['email'] ?? '', + $row['phone'] ?? '', $row['career_level'] ?? '', $row['sponsor'] ?? '', $row['customer_points'] ?? 0, @@ -334,7 +360,10 @@ class BackofficeStatisticsController extends Controller } if ($metric === 'new_partners') { - return ['Summe', $summary['count'].' Eintraege', '', '', $summary['customer_points'], $summary['total_points'], '', '']; + // Neun Werte fuer neun Spalten: die Telefonspalte kam nachtraeglich dazu, + // wodurch die Summen um eine Spalte verrutscht waren (Kundenpunkte + // standen unter "Sponsor"). + return ['Summe', $summary['count'].' Eintraege', '', '', '', $summary['customer_points'], $summary['total_points'], '', '']; } return ['Summe', $summary['count'].' Eintraege', '', '', '', '']; diff --git a/app/Models/UserAbo.php b/app/Models/UserAbo.php index e3ea3d91..b14f1774 100644 --- a/app/Models/UserAbo.php +++ b/app/Models/UserAbo.php @@ -7,6 +7,7 @@ namespace App\Models; use App\Enums\PaymentProvider; +use App\Services\Abo\TeamAboOverviewService; use App\Services\AboPaymentMethodService; use App\Services\Backoffice\BackofficeDashboardService; use App\Services\Util; @@ -106,6 +107,72 @@ class UserAbo extends Model $abo->saveQuietly(); } }); + + static::updated(function (UserAbo $abo): void { + $abo->logStatusTransition(); + }); + } + + /** + * Kanal des laufenden Schreibvorgangs (Formular-View) für das Statusprotokoll. + * Systempfade — Cron, Zahlungs-Recovery, Webhooks — lassen ihn leer und + * erscheinen in der Historie als „System". + */ + protected static ?string $statusLogView = null; + + /** + * Führt den Callback aus und schreibt alle dabei entstehenden Protokolleinträge + * mit diesem Kanal. Verschachtelung ist erlaubt: der vorherige Kanal wird + * wiederhergestellt, auch wenn der Callback eine Exception wirft. + */ + public static function withStatusLogView(?string $view, \Closure $callback): mixed + { + $previous = static::$statusLogView; + static::$statusLogView = $view; + + try { + return $callback(); + } finally { + static::$statusLogView = $previous; + } + } + + /** + * A16: Jeder Wechsel des Aktiv-Zustands wird protokolliert, unabhängig vom + * Schreibpfad (Formular, Admin, Zahlungs-Recovery, Retry, externe Erledigung). + * + * Hintergrund: Die historische Bestandsrückrechnung in + * {@see TeamAboOverviewService::aboStockQuery()} verwirft ein + * Abo, solange eine protokollierte Deaktivierung nicht durch eine spätere + * Reaktivierung aufgehoben ist. Ein Schreibpfad, der still auf `active = true` + * zurücksetzt, nimmt das Abo damit dauerhaft aus allen vergangenen Monaten — + * deshalb liegt der Eintrag hier am Model und nicht in den einzelnen Diensten. + * + * Grenzen: Model-Events feuern weder bei Query-Builder-Massenupdates noch bei + * `saveQuietly()`. Das Löschen braucht keinen Eintrag, die Rückrechnung wertet + * dafür `deleted_at` aus. + */ + protected function logStatusTransition(): void + { + if (! $this->wasChanged('active')) { + return; + } + + if ($this->active) { + $action = UserAboStatusHistory::ACTION_REACTIVATED; + } else { + // Status 4 = gekündigt; jede andere Abschaltung ist eine Deaktivierung. + $action = (int) $this->status === 4 + ? UserAboStatusHistory::ACTION_CANCELLED + : UserAboStatusHistory::ACTION_DEACTIVATED; + } + + UserAboStatusHistory::create([ + 'user_abo_id' => $this->id, + 'action' => $action, + 'view' => static::$statusLogView, + 'actor_user_id' => \Auth::id(), + ]); } protected $casts = [ @@ -196,6 +263,36 @@ class UserAbo extends Model ->whereNotIn('status', [4, 5, 6]); } + /** + * Abos mit noch offener Ausführung im Fenster von heute bis $windowEnd — + * gemeinsame Grundlage aller Prognosen (Payline-Kachel, persönliche + * Terminvorschau, Team-Übersicht). + * + * A13: Ein Abo mit `next_date` = heute galt bisher ab Mitternacht als + * erledigt (`next_date > heute 23:59:59`). Der Ausführungsjob läuft aber erst + * um 04:00 — bis dahin fehlten die Punkte in der Prognose UND im Ist. Deshalb + * zählt der heutige Liefertag mit, solange für das Abo heute noch keine + * Ausführung angelegt wurde. Sobald sie existiert, stecken die Punkte im Ist; + * die Bedingung verhindert also gerade die Doppelzählung. + */ + public function scopeOpenExecutionUntil(Builder $query, Carbon $today, Carbon $windowEnd): Builder + { + $nextDate = $query->qualifyColumn('next_date'); + + return $query + ->where($nextDate, '<=', $windowEnd) + ->where(function (Builder $open) use ($nextDate, $today) { + $open + ->where($nextDate, '>', $today->copy()->endOfDay()) + ->orWhere(fn (Builder $dueToday) => $dueToday + ->whereDate($nextDate, $today->toDateString()) + ->whereDoesntHave('user_abo_orders', fn ($order) => $order + ->whereDate('user_abo_orders.created_at', $today->toDateString()) + ) + ); + }); + } + /** * Schränkt die Abfrage auf Abos ein, die im angegebenen Monat/Jahr hinzugekommen * sind (Eingangsmonat = start_date). Bei month=0 (oder außerhalb 1–12) bleibt die diff --git a/app/Repositories/AboRepository.php b/app/Repositories/AboRepository.php index 043ff9bc..2c417c5a 100644 --- a/app/Repositories/AboRepository.php +++ b/app/Repositories/AboRepository.php @@ -49,6 +49,14 @@ class AboRepository extends BaseRepository } public function update($data) + { + // Der Kanal wird für den gesamten Schreibvorgang gesetzt, damit auch die + // zentral am Model protokollierten Zustandswechsel wissen, aus welchem + // Formular sie stammen ({@see UserAbo::logStatusTransition()}). + return UserAbo::withStatusLogView($data['view'] ?? null, fn () => $this->updateWithinStatusLogView($data)); + } + + private function updateWithinStatusLogView($data) { if (isset($data['action'])) { if ($data['action'] === 'abo_update_settings') { @@ -83,6 +91,13 @@ class AboRepository extends BaseRepository public function create($data) {} + /** + * Prüft die Regeln für Kündigung, Deaktivierung und Reaktivierung und setzt den + * neuen Zustand. Der Protokolleintrag entsteht nicht hier, sondern zentral in + * {@see UserAbo::logStatusTransition()} — sonst fehlt er in jedem Schreibpfad, + * der nicht über dieses Formular läuft. Nur die Pause protokolliert selbst, weil + * sie den Aktiv-Zustand nicht ändert und die Datumsverschiebung mitführt. + */ private function updateStatus($data) { $isAdminUpdate = $this->isAdminUpdate($data); @@ -114,12 +129,18 @@ class AboRepository extends BaseRepository $this->model->cancel_date = now(); $this->model->save(); - $this->logStatusHistory(UserAboStatusHistory::ACTION_CANCELLED, $data); - return; } - $active = (isset($data['abo_is_active']) && $data['abo_is_active']) ? true : false; + // A01: Im Selbstbedienungspfad ist der Aktiv-Zustand kein Eingabefeld. Das + // Formular spiegelt ihn nur in einem versteckten Feld zurück; würde er von + // dort übernommen, liesse sich die Mindestlaufzeit durch dauerhafte + // Deaktivierung umgehen und der Ein-Zyklus-Pausenpfad samt seiner Sperren + // ganz überspringen. Zustandswechsel laufen hier ausschliesslich über die + // dafür zugelassenen Aktionen Pause und Kündigung. + $active = $isAdminUpdate + ? (bool) ($data['abo_is_active'] ?? false) + : (bool) $this->model->active; // Sperre: 3 Tage vor Ausführung kann nicht mehr pausiert werden if (! $isAdminUpdate && $this->model->active && ! $active && $this->model->next_date) { $daysUntil = (int) now()->diffInDays(Carbon::parse($this->model->next_date), false); @@ -135,8 +156,6 @@ class AboRepository extends BaseRepository $this->model->status = 6; // inactive $this->model->active = false; $this->model->save(); - - $this->logStatusHistory(UserAboStatusHistory::ACTION_DEACTIVATED, $data); } } if (! $this->model->active && $active) { @@ -144,8 +163,6 @@ class AboRepository extends BaseRepository $this->model->status = 2; // okay $this->model->active = true; $this->model->save(); - - $this->logStatusHistory(UserAboStatusHistory::ACTION_REACTIVATED, $data); } } $this->model->active = $active; diff --git a/app/Services/Abo/PaylineForecastService.php b/app/Services/Abo/PaylineForecastService.php index b0359333..480f20b3 100644 --- a/app/Services/Abo/PaylineForecastService.php +++ b/app/Services/Abo/PaylineForecastService.php @@ -10,6 +10,7 @@ use App\Services\BusinessPlan\LiveCommissionService; use App\User; use Carbon\Carbon; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; /** @@ -40,6 +41,7 @@ class PaylineForecastService public function __construct( private BackofficeDashboardService $dashboardService, private LiveCommissionService $commissionService, + private TeamAboOverviewService $teamAboOverview, ) {} /** @@ -48,19 +50,27 @@ class PaylineForecastService * bleiben live. Ein fehlgeschlagener Live-Lauf wird nicht gecacht, damit * ein transienter Fehler die Karte nicht 6 h lang leert. * - * @return array{paylines: int, member_ids: int[]} + * Ebenso wenig gecacht wird eine Tiefe von 0: dieser Zustand tritt am + * Monatsanfang bei jedem Berater ohne Karriere-Level auf und löst sich mit + * den ersten Punkten des Monats von selbst auf — ein 6-h-Cache würde die + * leeren Kacheln bis in den Nachmittag festhalten. + * + * @return array{paylines: int, member_ids: int[], is_career_fallback: bool} */ public function paylineContext(User $user, int $month, int $year): array { $cacheKey = "payline_context_{$user->id}_{$month}_{$year}"; $cached = Cache::get($cacheKey); if (is_array($cached)) { - return $cached; + // Union statt direkter Rueckgabe: Eintraege aus der Zeit vor dem + // Karriere-Fallback kennen is_career_fallback noch nicht und wuerden + // beim Leser sonst in eine undefinierte Schluesselwarnung laufen. + return $cached + ['is_career_fallback' => false]; } [$context, $failed] = $this->buildPaylineContext($user, $month, $year); - if (! $failed) { + if (! $failed && $context['paylines'] > 0) { Cache::put($cacheKey, $context, now()->addHours(self::CACHE_HOURS)); } @@ -90,27 +100,20 @@ class PaylineForecastService /** * Neue Abos der Payline-Mitglieder im Monat (Berater- + Kundenabos, - * start_date im Monat) — für die "Neue Abos in der Payline"-Kachel (N1). + * start_date im Monat) — das "+N"-Badge der "Abos in der Payline"-Kachel (N1). + * + * Zählt über {@see TeamAboOverviewService::newAboCount()} und damit über + * exakt dieselbe Stichtags-Logik wie der Bestand der Kachel: für vergangene + * Monate zählt, was am Monatsende bestand. Mit `activeAbo()` (heutiger Stand) + * wäre das Badge kleiner als der Bestand daneben und würde mit jedem + * beendeten Abo rückwirkend weiter schrumpfen. */ public function newPaylineAboCount(User $user, int $month, int $year): int { $memberIds = $this->paylineContext($user, $month, $year)['member_ids']; - if ($memberIds === []) { - return 0; - } - - $startDate = Carbon::create($year, $month, 1)->startOfMonth(); - $endDate = $startDate->copy()->endOfMonth(); - - return UserAbo::query() - ->activeAbo() - ->whereBetween('start_date', [$startDate, $endDate]) - ->where(function (Builder $query) use ($memberIds) { - $query->where(fn (Builder $inner) => $inner->where('is_for', 'me')->whereIn('user_id', $memberIds)) - ->orWhere(fn (Builder $inner) => $inner->where('is_for', 'ot')->whereIn('member_id', $memberIds)); - }) - ->count(); + return $this->teamAboOverview->newAboCount($memberIds, 'user_id', 'me', $month, $year) + + $this->teamAboOverview->newAboCount($memberIds, 'member_id', 'ot', $month, $year); } /** @@ -135,8 +138,9 @@ class PaylineForecastService * B10: Prognose je Ausführungstermin des LAUFENDEN Monats. Basis ist der * heutige Stand (Kundenumsatz + Payline-Punkte, vom Aufrufer übergeben); * dazu kommen kumuliert die Abo-Punkte aller noch offenen Ausführungen bis - * zum jeweiligen Termin (next_date > heute, im laufenden Monat). Bereits - * ausgeführte Abos stecken im Ist-Umsatz und werden nicht doppelt gezählt. + * zum jeweiligen Termin (im laufenden Monat, Abgrenzung siehe + * {@see UserAbo::scopeOpenExecutionUntil()}). Bereits ausgeführte Abos + * stecken im Ist-Umsatz und werden nicht doppelt gezählt. * * @return array{base: float, days: array} */ @@ -149,8 +153,56 @@ class PaylineForecastService ->with('user_abo_items.product') ->get(); + return [ + 'base' => $basePoints, + 'days' => $this->cumulateByDeliveryDay($upcomingAbos, $basePoints, $today), + ]; + } + + /** + * Dieselbe Termin-Vorschau, aber ausschließlich über die EIGENEN Kundenabos + * des Beraters (Kundenwunsch 2026-09-07: „wieviel Volumenpunkte habe ich am + * 5. / 10. / 20. / 25. — nur mit meinen Kundenabos"). + * + * Bewusst ohne das eigene Abo und ohne Payline: Basis ist 0, die Kacheln + * zeigen also die Punkte, die aus den eigenen Kundenabos bis zum jeweiligen + * Termin noch hereinkommen. Das ist ein anderer Zuschnitt als + * {@see forecastDays()}, wo eigene Abos in einer Team-Zahl mitlaufen. + * + * @return array{base: float, days: array} + */ + public function personalCustomerAboForecast(User $user): array + { + $today = Carbon::today(); + + $abos = UserAbo::query() + ->where('active', true) + ->where('status', 2) + ->where('is_for', 'ot') + ->where('member_id', $user->id) + ->openExecutionUntil($today, $today->copy()->endOfMonth()) + ->with('user_abo_items.product') + ->get(); + + return [ + 'base' => 0.0, + 'days' => $this->cumulateByDeliveryDay($abos, 0.0, $today), + ]; + } + + /** + * Offene Ausführungen auf die vier Liefertermine kumulieren: je Termin die + * Punkte aller Abos, deren nächste Ausführung bis einschließlich zu diesem + * Tag fällt. Eine Stelle für beide Prognosen, damit Payline- und + * Kundenabo-Sicht nicht mit unterschiedlichen Terminregeln rechnen. + * + * @param Collection $abos + * @return array + */ + private function cumulateByDeliveryDay($abos, float $basePoints, Carbon $today): array + { $pointsByDay = []; - foreach ($upcomingAbos as $abo) { + foreach ($abos as $abo) { $day = (int) Carbon::parse($abo->getRawOriginal('next_date'))->day; $pointsByDay[$day] = ($pointsByDay[$day] ?? 0.0) + (float) $abo->getTotalPoints(); } @@ -173,10 +225,7 @@ class PaylineForecastService ]; } - return [ - 'base' => $basePoints, - 'days' => $days, - ]; + return $days; } /** @@ -219,14 +268,24 @@ class PaylineForecastService private function buildLevelAndCommissionForecast(User $user, int $month, int $year): ?array { $snapshot = $this->commissionService->snapshot($user, $month, $year); - $qualLevel = $snapshot['qual_level']; - if (! is_array($qualLevel) || ! empty($snapshot['failed'])) { + if (! empty($snapshot['failed'])) { + return null; // echter Berechnungsfehler — dafuer gibt es keine Prognose + } + + $qualLevel = is_array($snapshot['qual_level']) ? $snapshot['qual_level'] : null; + $nextLevel = is_array($snapshot['next_level']) ? $snapshot['next_level'] : null; + + // "Noch nicht qualifiziert" ist ein gueltiger Ausgangszustand: wer im Monat + // noch keine Stufe erreicht hat, kann die erste durch kommende eigene + // Abo-Punkte trotzdem erreichen. Nur wenn es weder eine erreichte noch eine + // naechste Stufe gibt, fehlt der Prognose jeder Bezugspunkt. + if ($qualLevel === null && $nextLevel === null) { return null; } $today = Carbon::today(); - $lineByUserId = $this->lineByUserId($user); + $lineByUserId = $this->lineByUserId($user, $month, $year); $upcoming = $this->upcomingPointsByLine($user, $lineByUserId, $today, $month, $year); $linePointsForecast = $this->currentLinePoints($lineByUserId, $month, $year); @@ -235,7 +294,6 @@ class PaylineForecastService } $personalForecast = (float) $snapshot['personal_points'] + $upcoming['personal']; - $nextLevel = is_array($snapshot['next_level']) ? $snapshot['next_level'] : null; $reached = false; $missingPp = 0.0; @@ -249,6 +307,9 @@ class PaylineForecastService $reached = $missingPp <= 0.0 && $missingPersonal <= 0.0; } + // Ohne erreichte Stufe und ohne prognostizierten Aufstieg gibt es keine + // Provisionssaetze — die PP-Provision bleibt dann 0, Shop und Wachstum zaehlen + // trotzdem. $estimationLevel = ($reached && $nextLevel !== null) ? $nextLevel : $qualLevel; $commissionPp = 0.0; for ($line = 1; $line <= (int) ($estimationLevel['paylines'] ?? 0); $line++) { @@ -270,23 +331,63 @@ class PaylineForecastService } /** - * Ebenen-Zuordnung der gesamten Downline (Linie je User-ID). + * Ebenen-Zuordnung der Downline (Linie je User-ID) — beschränkt auf die + * Mitglieder, die auch im Provisionsbaum stehen. + * + * Das ist entscheidend für die Vergleichbarkeit: Die Level-Kachel zeigt die + * payline_points aus dem TreeCalcBot, dieser Forecast rechnet die Ebenen aus + * {@see BackofficeDashboardService::lineBuckets()} nach. lineBuckets() nimmt + * aber JEDEN nicht gelöschten Downline-User, der TreeCalcBot filtert + * ({@see BusinessUserItemOptimized::readParentsBusinessUsers()}) und kappt + * den Unterbaum unter jedem ausgefilterten Knoten. Ohne denselben Filter + * stünden auf einer Seite zwei verschiedene "fehlen noch X Punkte". * * @return array */ - private function lineByUserId(User $user): array + private function lineByUserId(User $user, int $month, int $year): array { - $map = []; + $endOfMonth = Carbon::create($year, $month, 1)->endOfMonth(); + $map = []; + $pruned = []; + + // lineBuckets() liefert die Ebenen aufsteigend (BFS) — der Sponsor ist + // damit immer schon entschieden, wenn sein Teampartner drankommt. foreach ($this->dashboardService->lineBuckets($user->id) as $line => $users) { foreach ($users as $member) { - $map[(int) $member->id] = (int) $line; + $memberId = (int) $member->id; + + if (isset($pruned[(int) $member->m_sponsor]) || ! $this->countsInCommissionTree($member, $endOfMonth)) { + $pruned[$memberId] = true; + + continue; + } + + $map[$memberId] = (int) $line; } } return $map; } + /** + * Dieselben Bedingungen, mit denen der TreeCalcBot seine Struktur lädt: + * kein System-/Adminkonto, Mitgliedsebene und Abrechnungskonto gesetzt, bis + * zum Monatsende aktiv geworden. + */ + private function countsInCommissionTree(User $member, Carbon $endOfMonth): bool + { + if ((int) $member->id === 1 || $member->admin === null || (int) $member->admin >= 4) { + return false; + } + + if ($member->m_level === null || $member->payment_account === null || $member->active_date === null) { + return false; + } + + return Carbon::parse($member->active_date)->lte($endOfMonth); + } + /** * Offene Abo-Ausführungen des Monats, aufgeteilt in persönliches Volumen * (eigene Abos + eigene Kundenabos) und Team-Punkte je Ebene. @@ -376,15 +477,36 @@ class PaylineForecastService } /** - * @return array{0: array{paylines: int, member_ids: int[]}, 1: bool} [Kontext, Lauf fehlgeschlagen] + * Payline-Tiefe bestimmen und die Mitglieder der bezahlten Ebenen einsammeln. + * + * Die Tiefe kommt aus der MONATLICHEN Qualifikationsstufe. Die wird jeden + * Monat neu erarbeitet und ist am 1. für jeden Berater noch leer + * ({@see BusinessUserItemOptimized::calcuQualLevel()} findet erst ab der + * qual_kp des niedrigsten Levels ein Ergebnis) — die Struktur-Kacheln + * "Teampartner in der Payline" und "Abos in der Payline" stünden dann auf 0, + * obwohl sich an der Teamstruktur nichts geändert hat. + * + * Solange die Monatsquali aussteht, gilt deshalb die Ebenenzahl des + * Karriere-Levels (Kundenentscheid 2026-09-01). Punkte- und Provisionswerte + * bleiben davon unberührt: die kommen direkt aus dem Snapshot und sind ohne + * Qualifikation korrekt 0. + * + * @return array{0: array{paylines: int, member_ids: int[], is_career_fallback: bool}, 1: bool} [Kontext, Lauf fehlgeschlagen] */ private function buildPaylineContext(User $user, int $month, int $year): array { $snapshot = $this->commissionService->snapshot($user, $month, $year); + $failed = (bool) ($snapshot['failed'] ?? false); $paylines = $snapshot['paylines']; + $isCareerFallback = false; if ($paylines <= 0) { - return [['paylines' => 0, 'member_ids' => []], (bool) ($snapshot['failed'] ?? false)]; + $paylines = (int) ($user->user_level?->paylines ?? 0); + $isCareerFallback = $paylines > 0; + } + + if ($paylines <= 0) { + return [['paylines' => 0, 'member_ids' => [], 'is_career_fallback' => false], $failed]; } $memberIds = collect($this->dashboardService->lineBuckets($user->id)) @@ -394,7 +516,7 @@ class PaylineForecastService ->values() ->all(); - return [['paylines' => $paylines, 'member_ids' => $memberIds], false]; + return [['paylines' => $paylines, 'member_ids' => $memberIds, 'is_career_fallback' => $isCareerFallback], $failed]; } /** @@ -460,8 +582,7 @@ class PaylineForecastService return UserAbo::query() ->where('active', true) ->where('status', 2) - ->where('next_date', '>', $today->copy()->endOfDay()) - ->where('next_date', '<=', $today->copy()->endOfMonth()) + ->openExecutionUntil($today, $today->copy()->endOfMonth()) ->where(function (Builder $query) use ($aboUserIds) { $query->where(fn (Builder $inner) => $inner->where('is_for', 'me')->whereIn('user_id', $aboUserIds)) ->orWhere(fn (Builder $inner) => $inner->where('is_for', 'ot')->whereIn('member_id', $aboUserIds)); diff --git a/app/Services/Abo/TeamAboOverviewService.php b/app/Services/Abo/TeamAboOverviewService.php index 08787ca9..1e7c4552 100644 --- a/app/Services/Abo/TeamAboOverviewService.php +++ b/app/Services/Abo/TeamAboOverviewService.php @@ -137,13 +137,15 @@ class TeamAboOverviewService } /** - * Die Bestandsabfrage hinter {@see aboStockCount()} — offen, damit die - * "+N neu"-Zählung ({@see newAboCount()}) exakt dieselbe Stichtags-Logik - * verwendet und nicht in derselben Kachel eine andere Zählweise zeigt. + * Die Bestandsabfrage hinter {@see aboStockCount()} — öffentlich, damit jede + * "+N neu"-Zählung exakt dieselbe Stichtags-Logik verwendet und nicht in + * derselben Kachel eine andere Zählweise steht. Wer nur eine Gesamtzahl + * braucht, nimmt {@see newAboCount()}; gruppierte Auswertungen + * ({@see TopResultsService}) setzen direkt auf dieser Abfrage auf. * * @param int[] $userIds */ - private function aboStockQuery(array $userIds, string $userColumn, string $isFor, int $month, int $year): Builder + public function aboStockQuery(array $userIds, string $userColumn, string $isFor, int $month, int $year): Builder { $endOfMonth = Carbon::create($year, $month, 1)->endOfMonth(); @@ -173,7 +175,18 @@ class TeamAboOverviewService ->whereColumn('r.user_abo_id', 'h.user_abo_id') ->where('r.action', UserAboStatusHistory::ACTION_REACTIVATED) ->where('r.created_at', '<=', $endOfMonth) - ->whereColumn('r.created_at', '>', 'h.created_at'); + // Gleicher Zeitstempel kommt vor (created_at ist + // sekundengenau): dann entscheidet die Reihenfolge der + // Einträge, sonst gilt eine Reaktivierung in derselben + // Sekunde nicht und das Abo fällt aus dem Bestand. + ->where(function ($later) { + $later + ->whereColumn('r.created_at', '>', 'h.created_at') + ->orWhere(fn ($sameSecond) => $sameSecond + ->whereColumn('r.created_at', '=', 'h.created_at') + ->whereColumn('r.id', '>', 'h.id') + ); + }); }); }) // Heute beendete Abos ohne datierbares Ende (z. B. Admin-Toggle vor @@ -407,9 +420,10 @@ class TeamAboOverviewService /** * Abo-Punkte der noch offenen Ausführungen bis einschließlich zum letzten - * Liefertag (25.) des Monats: aktive Abos der Berater mit next_date nach - * heute und im angefragten Monat. Für vergangene/zukünftige Monate 0 — - * die Prognose ergibt nur für den laufenden Monat Sinn. + * Liefertag (25.) des Monats: aktive Abos der Berater, deren Ausführung noch + * aussteht ({@see UserAbo::scopeOpenExecutionUntil()}). Für + * vergangene/zukünftige Monate 0 — die Prognose ergibt nur für den laufenden + * Monat Sinn. * * @param int[] $userIds */ @@ -424,7 +438,10 @@ class TeamAboOverviewService $lastDeliveryDay = (int) max(UserAbo::$aboDeliveryDays); $windowEnd = Carbon::create($year, $month, 1)->addDays($lastDeliveryDay - 1)->endOfDay(); - if ($windowEnd->lte($today->copy()->endOfDay())) { + // A13: Am 25. selbst ist das Fenster noch offen, solange der + // Ausführungsjob nicht gelaufen ist — erst ab dem 26. gibt es nichts mehr + // zu prognostizieren. + if ($windowEnd->lt($today->copy()->startOfDay())) { return 0.0; } @@ -435,8 +452,7 @@ class TeamAboOverviewService ->where('user_abos.active', true) ->where('user_abos.status', 2) ->where('user_abo_items.comp', 0) - ->where('user_abos.next_date', '>', $today->copy()->endOfDay()) - ->where('user_abos.next_date', '<=', $windowEnd) + ->openExecutionUntil($today, $windowEnd) ->where(function (Builder $query) use ($userIds) { $query->where(fn (Builder $inner) => $inner->where('user_abos.is_for', 'me')->whereIn('user_abos.user_id', $userIds)) ->orWhere(fn (Builder $inner) => $inner->where('user_abos.is_for', 'ot')->whereIn('user_abos.member_id', $userIds)); @@ -487,8 +503,12 @@ class TeamAboOverviewService * * @param int[] $userIds */ - private function newAboCount(array $userIds, string $userColumn, string $isFor, int $month, int $year): int + public function newAboCount(array $userIds, string $userColumn, string $isFor, int $month, int $year): int { + if ($userIds === []) { + return 0; + } + $startDate = Carbon::create($year, $month, 1)->startOfMonth(); return $this->aboStockQuery($userIds, $userColumn, $isFor, $month, $year) diff --git a/app/Services/AboHelper.php b/app/Services/AboHelper.php index 8025d77d..8876c2ed 100644 --- a/app/Services/AboHelper.php +++ b/app/Services/AboHelper.php @@ -526,6 +526,10 @@ class AboHelper : (int) $userAbo->payone_userid; \DB::transaction(function () use ($userAbo, $order, $aboInterval, $shopping_payment, $payoneUserId, $isStripe): void { + // War das Abo deaktiviert, ist das hier eine Reaktivierung. Der + // Protokolleintrag entsteht in derselben Transaktion am Model + // ({@see UserAbo::logStatusTransition()}) — ohne ihn verschwaende das Abo + // aus der historischen Bestandsrueckrechnung. $userAbo->update([ 'status' => 2, 'active' => true, diff --git a/app/Services/BusinessPlan/LiveCommissionService.php b/app/Services/BusinessPlan/LiveCommissionService.php index 6fa1d1fe..6fc7db5f 100644 --- a/app/Services/BusinessPlan/LiveCommissionService.php +++ b/app/Services/BusinessPlan/LiveCommissionService.php @@ -73,7 +73,12 @@ class LiveCommissionService /** * Provision "Stand heute" (B1) — alle Bestandteile plus Summe. * - * @return array{total: float, pp: float, shop: float, growth: float, is_live: bool} + * `failed` wird mit durchgereicht: eine fehlgeschlagene Live-Berechnung liefert + * Ersatzwerte, aus denen sich rechnerisch 0,00 Euro ergibt. Ohne dieses Kennzeichen + * waere ein Berechnungsfehler in der Oberflaeche nicht von einem echten Nullmonat + * zu unterscheiden. + * + * @return array{total: float, pp: float, shop: float, growth: float, is_live: bool, failed: bool} */ public function commission(User $user, int $month, int $year): array { @@ -85,6 +90,7 @@ class LiveCommissionService 'shop' => $snapshot['shop'], 'growth' => $snapshot['growth'], 'is_live' => $snapshot['is_live'], + 'failed' => (bool) ($snapshot['failed'] ?? false), ]; } @@ -151,19 +157,29 @@ class LiveCommissionService * Y persönliches Volumen"; Karriere-Level als Zusatzinfo. Eine Stelle für * die Rechnung, damit Kachel und Prognose nicht auseinanderlaufen. * - * @return array{name: string, career_name: string, next_name: string|null, missing_pp: float, missing_personal: float, is_live: bool}|null + * Vor der ersten Monatsqualifikation ist `qual_level` null — dann liefert + * {@see BusinessUserItemOptimized::setFirstQualLevel()} das erste erreichbare + * Level als `next_level` mit. Der Block darf in diesem Zustand NICHT + * verschwinden: genau dann ist "dir fehlen noch X Punkte" die nützlichste + * Information der Seite. `is_qualified` unterscheidet die beiden Fälle. + * + * @return array{name: string, is_qualified: bool, career_name: string, next_name: string|null, missing_pp: float, missing_personal: float, is_live: bool}|null */ public function levelProgress(User $user, int $month, int $year): ?array { $snapshot = $this->snapshot($user, $month, $year); $qualLevel = $snapshot['qual_level']; - if (! is_array($qualLevel) || ! empty($snapshot['failed'])) { + if (! empty($snapshot['failed'])) { return null; } $nextLevel = $snapshot['next_level']; + if (! is_array($qualLevel) && ! is_array($nextLevel)) { + return null; + } + // Punktebasis zum nächsten Level: dessen _calculated-Wert rechnet mit den // Paylines/dem KP-Überschuss DES nächsten Levels — die eigene // payline_points_qual_kp (erreichtes Level) wäre bei mehr Paylines zu klein. @@ -172,7 +188,8 @@ class LiveCommissionService : 0.0; return [ - 'name' => (string) ($qualLevel['name'] ?? ''), + 'name' => is_array($qualLevel) ? (string) ($qualLevel['name'] ?? '') : '', + 'is_qualified' => is_array($qualLevel), 'career_name' => $user->user_level?->getLang('name') ?? '', 'next_name' => is_array($nextLevel) ? (string) ($nextLevel['name'] ?? '') : null, 'missing_pp' => is_array($nextLevel) diff --git a/app/Services/Team/AnnualFeeDueService.php b/app/Services/Team/AnnualFeeDueService.php index 33ab26b1..8e2551a5 100644 --- a/app/Services/Team/AnnualFeeDueService.php +++ b/app/Services/Team/AnnualFeeDueService.php @@ -85,13 +85,19 @@ class AnnualFeeDueService return $groups; } + /** + * "Innerhalb eines Monats" heisst: gleicher Tag im Folgemonat, an Monatsenden + * begrenzt auf dessen letzten Tag. `addMonth()` laeuft dort ueber — vom 31.01. + * aus landet es am 03.03. und haette den 02.03. noch als "innerhalb eines + * Monats" gezaehlt. + */ private function groupKeyFor(Carbon $dueDate, Carbon $now): ?string { return match (true) { $dueDate->lte($now) => self::GROUP_OVERDUE, - $dueDate->lte($now->copy()->addMonth()) => self::GROUP_DUE_1_MONTH, - $dueDate->lte($now->copy()->addMonths(2)) => self::GROUP_DUE_2_MONTHS, - $dueDate->lte($now->copy()->addMonths(3)) => self::GROUP_DUE_3_MONTHS, + $dueDate->lte($now->copy()->addMonthNoOverflow()) => self::GROUP_DUE_1_MONTH, + $dueDate->lte($now->copy()->addMonthsNoOverflow(2)) => self::GROUP_DUE_2_MONTHS, + $dueDate->lte($now->copy()->addMonthsNoOverflow(3)) => self::GROUP_DUE_3_MONTHS, default => null, }; } diff --git a/app/Services/Team/TopResultsService.php b/app/Services/Team/TopResultsService.php index 530a67bf..5c8a37cf 100644 --- a/app/Services/Team/TopResultsService.php +++ b/app/Services/Team/TopResultsService.php @@ -2,8 +2,8 @@ namespace App\Services\Team; -use App\Models\UserAbo; use App\Services\Abo\PaylineForecastService; +use App\Services\Abo\TeamAboOverviewService; use App\Services\Backoffice\BackofficeDashboardService; use App\Services\Backoffice\SalesVolumePoints; use App\User; @@ -33,7 +33,10 @@ class TopResultsService private const TOP_LIMIT = 3; - public function __construct(private BackofficeDashboardService $dashboardService) {} + public function __construct( + private BackofficeDashboardService $dashboardService, + private TeamAboOverviewService $teamAboOverview, + ) {} /** * @return array{ @@ -160,7 +163,13 @@ class TopResultsService /** * Neue aktive Abos im Monat je Berater: neue Kundenabos (member_id, is_for * 'ot') plus neues Eigenabo (user_id, is_for 'me') — Kundenentscheid N6/F7b. - * Zählweise wie {@see BackofficeDashboardService::newAboCount()}. + * + * Stichtags-Logik wie bei den Abo-Kacheln des Dashboards + * ({@see TeamAboOverviewService::aboStockQuery()}): für vergangene Monate + * zählt, was am Monatsende bestand. Mit `activeAbo()` (heutiger Stand) + * schrumpfte die Rangliste eines abgeschlossenen Monats mit jedem beendeten + * Abo nachträglich — die Top-3-Neupartner daneben sind über + * {@see User::scopeNewPartnerIn()} bereits stichtagsstabil. * * @param int[] $teamUserIds * @return array user_id => Anzahl @@ -174,11 +183,9 @@ class TopResultsService $startDate = Carbon::create($year, $month, 1)->startOfMonth(); $endDate = Carbon::create($year, $month, 1)->endOfMonth(); - $newAboQuery = fn (string $userColumn, string $isFor) => UserAbo::query() - ->activeAbo() + $newAboQuery = fn (string $userColumn, string $isFor) => $this->teamAboOverview + ->aboStockQuery($teamUserIds, $userColumn, $isFor, $month, $year) ->whereBetween('start_date', [$startDate, $endDate]) - ->whereIn($userColumn, $teamUserIds) - ->where('is_for', $isFor) ->groupBy($userColumn) ->selectRaw($userColumn.' as uid, COUNT(*) as value') ->pluck('value', 'uid'); @@ -196,8 +203,14 @@ class TopResultsService /** * Baut aus user_id=>Wert die Top-3-Zeilen (Foto/Name), sortiert nach Wert - * absteigend, bei Gleichstand nach Name. Es werden nur die Kandidaten - * hydriert, die die Top 3 noch erreichen können. + * absteigend, bei Gleichstand nach Name. + * + * A11: Der Namens-Tiebreak greift erst nach dem Wert — die Kandidatenmenge + * darf deshalb nicht mitten durch einen Gleichstand geschnitten werden, sonst + * entscheidet die Eingabereihenfolge der Abfrage statt des Namens. Deshalb + * zwei Schritte vor dem Hydrieren: Kandidaten ohne Account fallen vorher weg + * (sie könnten die Auswahl sonst leer laufen lassen), und die Auswahlgrenze + * wird um alle Gleichstände an dieser Grenze erweitert. * * @param array $valuesByUserId * @return array @@ -209,12 +222,22 @@ class TopResultsService return []; } - arsort($valuesByUserId); + // Nur Ids: die Namen kommen erst für die tatsächlich nötigen Kandidaten. + $withAccount = User::query() + ->whereIn('id', array_keys($valuesByUserId)) + ->whereHas('account') + ->pluck('id') + ->map(fn ($id) => (int) $id) + ->all(); + + $valuesByUserId = array_intersect_key($valuesByUserId, array_flip($withAccount)); + if ($valuesByUserId === []) { + return []; + } + + arsort($valuesByUserId); + $valuesByUserId = self::takeIncludingTies($valuesByUserId, self::TOP_LIMIT); - // Kandidaten in Wert-Reihenfolge hydrieren — Zeilen ohne Account werden - // übersprungen und von Nachrückern aufgefüllt; der Puffer begrenzt die - // Hydrierung bei großen Teams. - $valuesByUserId = array_slice($valuesByUserId, 0, self::TOP_LIMIT + 22, true); $users = User::query() ->with('account') ->whereIn('id', array_keys($valuesByUserId)) @@ -237,9 +260,43 @@ class TopResultsService ]; } - usort($rows, fn (array $a, array $b) => [$b['value'], $a['first_name'], $a['last_name']] - <=> [$a['value'], $b['first_name'], $b['last_name']]); + // user_id als letzte Regel: bei gleichem Wert UND gleichem Namen ist die + // Reihenfolge sonst nicht reproduzierbar. + usort($rows, fn (array $a, array $b) => [$b['value'], $a['first_name'], $a['last_name'], $a['user_id']] + <=> [$a['value'], $b['first_name'], $b['last_name'], $b['user_id']]); return array_slice($rows, 0, self::TOP_LIMIT); } + + /** + * Die ersten $limit Einträge einer absteigend sortierten Werteliste plus alle + * weiteren, die mit dem letzten davon gleichauf liegen. Damit ist garantiert, + * dass jeder Kandidat, der die Top 3 über den Namens-Tiebreak noch erreichen + * kann, in der Auswahl bleibt. + * + * @param array $sortedValues + * @return array + */ + private static function takeIncludingTies(array $sortedValues, int $limit): array + { + $taken = []; + $boundaryValue = null; + + foreach ($sortedValues as $userId => $value) { + if (count($taken) < $limit) { + $taken[$userId] = $value; + $boundaryValue = $value; + + continue; + } + + if ($value != $boundaryValue) { + break; + } + + $taken[$userId] = $value; + } + + return $taken; + } } diff --git a/resources/lang/de/abo.php b/resources/lang/de/abo.php index 260ea5d7..edd2faeb 100644 --- a/resources/lang/de/abo.php +++ b/resources/lang/de/abo.php @@ -49,7 +49,13 @@ return [ 'payline_target_label' => 'Soll (Punkte pro Zyklus)', 'payline_actual_label' => 'Ist (im Monat abgerechnet)', 'payline_hint' => 'Payline = :lines Ebenen · :members Berater', - 'payline_abo_counts' => ':team_abos Teamabos + :customer_abos Kundenabos in der Payline', + 'payline_abo_counts' => ':team_abos Teamabos + :customer_abos Kundenabos in der Payline = :total Abos gesamt', + 'abo_order_short' => '(Abo)', + 'points_order_legend' => 'Bestellungen: blau = eigene Bestellung · grau = Kundenbestellung · türkis mit „(Abo)" = Abo-Lieferung.', + 'customer_abo_forecast_title' => 'Punkte aus meinen Kundenabos zum 5. / 10. / 20. / 25.', + 'customer_abo_forecast_hint' => 'Kumuliert: die Punkte aller eigenen Kundenabos, deren Lieferung bis zum jeweiligen Termin dieses Monats noch aussteht. Ohne dein eigenes Abo und ohne Team.', + 'customer_abo_list_title' => 'Meine Kundenabos (:count)', + 'customer_abo_list_search' => 'Kundenabos durchsuchen...', 'payline_none' => 'Noch keine Payline vorhanden (kein Provisionslevel erreicht).', 'abo_points_per_cycle' => 'Punkte pro Lieferung (Bestand)', 'abo_points_billed' => 'abgerechnet (:month)', diff --git a/resources/lang/de/home.php b/resources/lang/de/home.php index c8d0375b..122e298e 100644 --- a/resources/lang/de/home.php +++ b/resources/lang/de/home.php @@ -49,7 +49,9 @@ return [ 'welcome_back' => 'Willkommen zurück', 'your_shop' => 'Dein Shop', 'commission_today' => 'Provision (Stand heute)', + 'commission_unavailable' => 'Die Provisionsberechnung ist gerade nicht verfuegbar. Der Wert wird nachgetragen, sobald sie wieder laeuft.', 'payline_forecast_title' => 'Payline-Prognose zum 5. / 10. / 20. / 25.', + 'payline_forecast_basis' => 'Basis: Paylinepunkte + persönliches Volumen', 'payline_forecast_hint' => 'Basis: aktueller Kundenumsatz + Teamumsatz (Payline). Dazu kommen kumuliert die bis zum jeweiligen Termin noch anstehenden Abo-Punkte deiner Payline (inkl. deiner eigenen Abos und Kundenabos). Prognose: Pausen, Kündigungen oder fehlgeschlagene Zahlungen können den Wert verändern.', 'forecast_day' => 'Prognose zum :date', 'forecast_upcoming' => 'Abo-Punkte offen', @@ -75,19 +77,24 @@ return [ 'team_partner_total' => 'Teampartner gesamt', 'payline_partner_count' => 'Teampartner in der Payline', 'payline_abo_count' => 'Abos in der Payline', + 'payline_career_fallback_hint' => 'Gezählt nach den Ebenen deines Karriere-Levels: deine Qualifikation für diesen Monat steht noch aus. Sobald sie feststeht, zählt die Kachel nach den Ebenen der Qualifikationsstufe.', 'level_current' => 'Qualifikation in diesem Monat', 'level_career' => 'Karriere-Level', 'level_next_missing' => 'Für die Qualifikation als :level in diesem Monat fehlen dir noch :points Payline-Punkte.', 'level_next_missing_personal' => 'Zusätzlich fehlen :points Punkte persönliches Volumen.', + 'level_not_qualified_yet' => 'Noch nicht qualifiziert', + 'level_next_missing_personal_only' => 'Für die Qualifikation als :level in diesem Monat fehlen dir noch :points Punkte persönliches Volumen.', 'level_top_reached' => 'Höchstes Level erreicht.', 'level_hint' => 'Monatliche Qualifikationsstufe aus dem Live-Lauf. Payline-Punkte = Teampunkte deiner bezahlten Ebenen inkl. deines Kundenpunkte-Überschusses.', 'forecast_level_title' => 'Level & Provision laut Prognose (Stand 25.)', 'forecast_unverbindlich' => 'unverbindlich', 'forecast_level_disclaimer' => 'Schätzung auf Basis der heutigen Punkte plus der noch offenen Abo-Lieferungen. Rang-/Strukturänderungen im Team sind nicht berücksichtigt - verbindlich ist erst der Monatsabschluss.', + 'forecast_level_needs_qualification' => 'Level- und Provisions-Prognose erscheint, sobald deine Qualifikation für diesen Monat feststeht — die Provisionssätze hängen an der Qualifikationsstufe.', 'forecast_level_reached' => 'Laut Prognose erreichst du das nächste Level :level - sofern dein persönliches Volumen von :volume Punkten erreicht wird.', 'forecast_level_missing' => 'Laut Prognose fehlen zum nächsten Level :level noch :points Payline-Punkte.', 'forecast_level_missing_personal' => 'Zusätzlich fehlen :points Punkte persönliches Volumen.', 'forecast_commission' => 'Voraussichtliche Provision laut Prognose: :amount (auf Basis Level :level).', + 'forecast_commission_no_level' => 'Voraussichtliche Provision laut Prognose: :amount. Team-Provision kommt hinzu, sobald die erste Qualifikationsstufe erreicht ist.', // N6: "Die besten Ergebnisse des Monats" (teamintern) 'top_results_title' => 'Die besten Ergebnisse des Monats (Team)', diff --git a/resources/lang/en/abo.php b/resources/lang/en/abo.php index 770987d0..911de7b0 100644 --- a/resources/lang/en/abo.php +++ b/resources/lang/en/abo.php @@ -175,7 +175,13 @@ return [ 'payline_target_label' => 'Target (points per cycle)', 'payline_actual_label' => 'Actual (billed this month)', 'payline_hint' => 'Payline = :lines levels · :members consultants', - 'payline_abo_counts' => ':team_abos team subscriptions + :customer_abos customer subscriptions in the payline', + 'payline_abo_counts' => ':team_abos team subscriptions + :customer_abos customer subscriptions in the payline = :total subscriptions in total', + 'abo_order_short' => '(subscr.)', + 'points_order_legend' => 'Orders: blue = your own order · grey = customer order · turquoise with "(subscr.)" = subscription delivery.', + 'customer_abo_forecast_title' => 'Points from my customer subscriptions for the 5th / 10th / 20th / 25th', + 'customer_abo_forecast_hint' => 'Cumulative: the points of all your own customer subscriptions whose delivery is still due by the respective date this month. Without your own subscription and without your team.', + 'customer_abo_list_title' => 'My customer subscriptions (:count)', + 'customer_abo_list_search' => 'Search customer subscriptions...', 'payline_none' => 'No payline yet (no commission level reached).', 'abo_points_per_cycle' => 'Points per delivery (current stock)', 'abo_points_billed' => 'billed (:month)', diff --git a/resources/lang/en/home.php b/resources/lang/en/home.php index 48f5e796..1ca82470 100644 --- a/resources/lang/en/home.php +++ b/resources/lang/en/home.php @@ -48,7 +48,9 @@ return [ 'welcome_back' => 'welcome back', 'your_shop' => 'your shop', 'commission_today' => 'Commission (as of today)', + 'commission_unavailable' => 'The commission calculation is currently unavailable. The value will appear once it runs again.', 'payline_forecast_title' => 'Payline forecast for the 5th / 10th / 20th / 25th', + 'payline_forecast_basis' => 'Basis: payline points + personal volume', 'payline_forecast_hint' => 'Basis: current customer turnover + team turnover (payline). Plus, cumulatively, the subscription points of your payline still due up to the respective date (incl. your own subscriptions and customer subscriptions). Forecast: pauses, cancellations or failed payments can change the value.', 'forecast_day' => 'Forecast for :date', 'forecast_upcoming' => 'subscription points due', @@ -75,19 +77,24 @@ return [ 'team_partner_total' => 'Team partners total', 'payline_partner_count' => 'Team partners in the payline', 'payline_abo_count' => 'Subscriptions in the payline', + 'payline_career_fallback_hint' => 'Counted across the levels of your career rank: your qualification for this month is still pending. Once it is set, the tile counts across the levels of the qualification rank.', 'level_current' => 'Qualification this month', 'level_career' => 'Career level', 'level_next_missing' => 'You still need :points payline points to qualify as :level this month.', 'level_next_missing_personal' => 'Additionally :points points of personal volume are missing.', + 'level_not_qualified_yet' => 'Not yet qualified', + 'level_next_missing_personal_only' => 'You still need :points points of personal volume to qualify as :level this month.', 'level_top_reached' => 'Highest level reached.', 'level_hint' => 'Monthly qualification level from the live run. Payline points = team points of your paid levels including your customer points surplus.', 'forecast_level_title' => 'Level & commission according to forecast (as of the 25th)', 'forecast_unverbindlich' => 'non-binding', 'forecast_level_disclaimer' => 'Estimate based on today\'s points plus the still open subscription deliveries. Rank/structure changes in the team are not taken into account - only the monthly closing is binding.', + 'forecast_level_needs_qualification' => 'The level and commission forecast appears as soon as your qualification for this month is set — the commission rates depend on the qualification rank.', 'forecast_level_reached' => 'According to the forecast you will reach the next level :level - provided your personal volume of :volume points is reached.', 'forecast_level_missing' => 'According to the forecast, :points payline points are still missing for the next level :level.', 'forecast_level_missing_personal' => 'Additionally :points points of personal volume are missing.', 'forecast_commission' => 'Expected commission according to forecast: :amount (based on level :level).', + 'forecast_commission_no_level' => 'Projected commission: :amount. Team commission is added once the first qualification level is reached.', // N6: "Best results of the month" (team-internal) 'top_results_title' => 'Best results of the month (team)', diff --git a/resources/lang/es/abo.php b/resources/lang/es/abo.php index c75176b5..bcdf611a 100644 --- a/resources/lang/es/abo.php +++ b/resources/lang/es/abo.php @@ -158,7 +158,13 @@ return [ 'payline_target_label' => 'Objetivo (puntos por ciclo)', 'payline_actual_label' => 'Real (facturado este mes)', 'payline_hint' => 'Payline = :lines niveles · :members asesores', - 'payline_abo_counts' => ':team_abos suscripciones de asesores + :customer_abos suscripciones de clientes en la payline', + 'payline_abo_counts' => ':team_abos suscripciones de equipo + :customer_abos suscripciones de clientes en la payline = :total suscripciones en total', + 'abo_order_short' => '(susc.)', + 'points_order_legend' => 'Pedidos: azul = pedido propio · gris = pedido de cliente · turquesa con «(susc.)» = entrega de suscripción.', + 'customer_abo_forecast_title' => 'Puntos de mis suscripciones de clientes para el 5 / 10 / 20 / 25', + 'customer_abo_forecast_hint' => 'Acumulado: los puntos de todas tus suscripciones de clientes cuya entrega aún está pendiente hasta la fecha correspondiente de este mes. Sin tu propia suscripción y sin el equipo.', + 'customer_abo_list_title' => 'Mis suscripciones de clientes (:count)', + 'customer_abo_list_search' => 'Buscar suscripciones de clientes...', 'payline_none' => 'Aún no hay payline (no se ha alcanzado ningún nivel de comisión).', 'abo_points_per_cycle' => 'Puntos por entrega (cartera actual)', 'abo_points_billed' => 'facturado (:month)', diff --git a/resources/lang/es/home.php b/resources/lang/es/home.php index 45f4aaa1..7d0163c7 100644 --- a/resources/lang/es/home.php +++ b/resources/lang/es/home.php @@ -48,7 +48,9 @@ return [ 'welcome_back' => 'bienvenido de nuevo', 'your_shop' => 'tu tienda', 'commission_today' => 'Comisión (a día de hoy)', + 'commission_unavailable' => 'El calculo de comisiones no esta disponible en este momento. El valor aparecera cuando vuelva a ejecutarse.', 'payline_forecast_title' => 'Pronóstico de payline para el 5 / 10 / 20 / 25', + 'payline_forecast_basis' => 'Base: puntos de payline + volumen personal', 'payline_forecast_hint' => 'Base: volumen de clientes actual + volumen del equipo (payline). Más, de forma acumulada, los puntos de suscripción de tu payline pendientes hasta la fecha correspondiente (incl. tus propias suscripciones y las de tus clientes). Pronóstico: pausas, cancelaciones o pagos fallidos pueden cambiar el valor.', 'forecast_day' => 'Pronóstico para el :date', 'forecast_upcoming' => 'puntos de suscripción pendientes', @@ -75,19 +77,24 @@ return [ 'team_partner_total' => 'Socios en total', 'payline_partner_count' => 'Socios en la payline', 'payline_abo_count' => 'Suscripciones en la payline', + 'payline_career_fallback_hint' => 'Contado según los niveles de tu nivel de carrera: tu calificación de este mes aún está pendiente. En cuanto esté fijada, la tarjeta contará según los niveles del nivel de calificación.', 'level_current' => 'Calificación de este mes', 'level_career' => 'Nivel de carrera', 'level_next_missing' => 'Para calificar como :level este mes te faltan :points puntos de payline.', 'level_next_missing_personal' => 'Además faltan :points puntos de volumen personal.', + 'level_not_qualified_yet' => 'Aún no calificado', + 'level_next_missing_personal_only' => 'Para calificar como :level este mes te faltan :points puntos de volumen personal.', 'level_top_reached' => 'Nivel más alto alcanzado.', 'level_hint' => 'Nivel de calificación mensual del cálculo en vivo. Puntos de payline = puntos de equipo de tus niveles pagados incluyendo tu excedente de puntos de clientes.', 'forecast_level_title' => 'Nivel y comisión según la previsión (al día 25)', 'forecast_unverbindlich' => 'sin compromiso', 'forecast_level_disclaimer' => 'Estimación basada en los puntos de hoy más las entregas de suscripción pendientes. No se consideran cambios de rango/estructura en el equipo - solo el cierre mensual es vinculante.', + 'forecast_level_needs_qualification' => 'La previsión de nivel y comisión aparece en cuanto se fija tu calificación de este mes: los porcentajes de comisión dependen del nivel de calificación.', 'forecast_level_reached' => 'Según la previsión alcanzarás el siguiente nivel :level - siempre que se alcance tu volumen personal de :volume puntos.', 'forecast_level_missing' => 'Según la previsión faltan :points puntos de payline para el siguiente nivel :level.', 'forecast_level_missing_personal' => 'Además faltan :points puntos de volumen personal.', 'forecast_commission' => 'Comisión prevista según la previsión: :amount (según nivel :level).', + 'forecast_commission_no_level' => 'Comision prevista segun el pronostico: :amount. La comision de equipo se anade en cuanto se alcanza el primer nivel de cualificacion.', // N6: "Los mejores resultados del mes" (interno del equipo) 'top_results_title' => 'Los mejores resultados del mes (equipo)', diff --git a/resources/lang/fr/abo.php b/resources/lang/fr/abo.php index d0d65aaf..bcc07148 100644 --- a/resources/lang/fr/abo.php +++ b/resources/lang/fr/abo.php @@ -49,7 +49,13 @@ return [ 'payline_target_label' => 'Objectif (points par cycle)', 'payline_actual_label' => 'Réel (facturé ce mois-ci)', 'payline_hint' => 'Payline = :lines niveaux · :members conseillers', - 'payline_abo_counts' => ':team_abos abonnements conseillers + :customer_abos abonnements clients dans la payline', + 'payline_abo_counts' => ':team_abos abonnements équipe + :customer_abos abonnements clients dans la payline = :total abonnements au total', + 'abo_order_short' => '(abo)', + 'points_order_legend' => 'Commandes : bleu = commande personnelle · gris = commande client · turquoise avec « (abo) » = livraison d’abonnement.', + 'customer_abo_forecast_title' => 'Points de mes abonnements clients pour le 5 / 10 / 20 / 25', + 'customer_abo_forecast_hint' => 'Cumulé : les points de tous tes abonnements clients dont la livraison est encore à venir jusqu’à la date concernée ce mois-ci. Sans ton propre abonnement et sans l’équipe.', + 'customer_abo_list_title' => 'Mes abonnements clients (:count)', + 'customer_abo_list_search' => 'Rechercher des abonnements clients...', 'payline_none' => 'Pas encore de payline (aucun niveau de commission atteint).', 'abo_points_per_cycle' => 'Points par livraison (portefeuille actuel)', 'abo_points_billed' => 'facturé (:month)', diff --git a/resources/lang/fr/home.php b/resources/lang/fr/home.php index e551d031..ca9a3e78 100644 --- a/resources/lang/fr/home.php +++ b/resources/lang/fr/home.php @@ -49,7 +49,9 @@ return [ 'welcome_back' => 'Bon retour', 'your_shop' => 'Votre boutique', 'commission_today' => 'Commission (à ce jour)', + 'commission_unavailable' => 'Le calcul des commissions n\'est pas disponible actuellement. La valeur apparaitra des qu\'il fonctionnera de nouveau.', 'payline_forecast_title' => 'Prévision payline pour le 5 / 10 / 20 / 25', + 'payline_forecast_basis' => 'Base : points payline + volume personnel', 'payline_forecast_hint' => 'Base : chiffre d’affaires clients actuel + chiffre d’affaires équipe (payline). S’y ajoutent, de manière cumulée, les points d’abonnement de ta payline encore à venir jusqu’à la date concernée (y compris tes propres abonnements et abonnements clients). Prévision : pauses, résiliations ou paiements échoués peuvent modifier la valeur.', 'forecast_day' => 'Prévision pour le :date', 'forecast_upcoming' => 'points d’abonnement à venir', @@ -75,19 +77,24 @@ return [ 'team_partner_total' => 'Partenaires au total', 'payline_partner_count' => 'Partenaires dans la payline', 'payline_abo_count' => 'Abonnements dans la payline', + 'payline_career_fallback_hint' => 'Compté sur les niveaux de ton niveau de carrière : ta qualification pour ce mois n\'est pas encore établie. Dès qu\'elle le sera, la tuile comptera sur les niveaux du niveau de qualification.', 'level_current' => 'Qualification de ce mois', 'level_career' => 'Niveau de carrière', 'level_next_missing' => 'Pour te qualifier comme :level ce mois-ci, il te manque encore :points points de payline.', 'level_next_missing_personal' => 'Il manque en plus :points points de volume personnel.', + 'level_not_qualified_yet' => 'Pas encore qualifié', + 'level_next_missing_personal_only' => 'Pour te qualifier comme :level ce mois-ci, il te manque encore :points points de volume personnel.', 'level_top_reached' => 'Niveau le plus élevé atteint.', 'level_hint' => 'Niveau de qualification mensuel issu du calcul en direct. Points de payline = points d’équipe de tes niveaux payés, y compris ton excédent de points clients.', 'forecast_level_title' => 'Niveau et commission selon la prévision (au 25)', 'forecast_unverbindlich' => 'sans engagement', 'forecast_level_disclaimer' => 'Estimation basée sur les points actuels plus les livraisons d’abonnement encore ouvertes. Les changements de rang/structure dans l’équipe ne sont pas pris en compte - seule la clôture mensuelle fait foi.', + 'forecast_level_needs_qualification' => 'La prévision de niveau et de commission apparaît dès que ta qualification pour ce mois est établie : les taux de commission dépendent du niveau de qualification.', 'forecast_level_reached' => 'Selon la prévision, tu atteindras le niveau suivant :level - à condition que ton volume personnel de :volume points soit atteint.', 'forecast_level_missing' => 'Selon la prévision, il manque encore :points points de payline pour le niveau suivant :level.', 'forecast_level_missing_personal' => 'Il manque en plus :points points de volume personnel.', 'forecast_commission' => 'Commission prévisionnelle selon la prévision : :amount (sur la base du niveau :level).', + 'forecast_commission_no_level' => 'Commission prevue selon la prevision : :amount. La commission d\'equipe s\'ajoute des que le premier niveau de qualification est atteint.', // N6 : « Les meilleurs résultats du mois » (interne à l'équipe) 'top_results_title' => 'Les meilleurs résultats du mois (équipe)', diff --git a/resources/views/components/_sortable_table.blade.php b/resources/views/components/_sortable_table.blade.php new file mode 100644 index 00000000..80476259 --- /dev/null +++ b/resources/views/components/_sortable_table.blade.php @@ -0,0 +1,143 @@ +{{-- Sortier- und Suchverhalten für einfache Tabellen ohne Serverpaging. + + Erwartet: + $tableId id der + $searchId id eines optionalen Suchfeldes (leer lassen, wenn keins da ist) + $minWidth optionale Mindestbreite der Tabelle (Default 980px) + + Sortierbare Spalten bekommen `data-sortable="true"` und optional + `data-sort-type="number|date"`. Für große Listen ist das nicht gedacht — + dort gehört Yajra-Datatables hin. + + Sortierwerte (A14): Zellen mit Zahlen, Datum oder mehrzeiligem Inhalt tragen + `data-sort-value` mit dem UNFORMATIERTEN Wert — Zahl mit Punkt als + Dezimaltrenner, Datum als `Y-m-d`. Der Zellentext ist lokalisiert + (`Util::_thousands_separator()` und `formatDate()` schalten bei `en` auf + `1,000.00` und `Y-m-d` um) und taugt deshalb nicht als Sortiergrundlage: + der Datumsparser erkennt nur `dd.mm.yyyy`, und der Zahlenparser macht aus + `1,000.00` eine 1. Ohne Attribut wird weiterhin der Text gelesen — das + stimmt nur im deutschen Format. --}} +@php + $sortableMinWidth = $minWidth ?? '980px'; + $sortableSearchId = $searchId ?? null; +@endphp + + + + diff --git a/resources/views/dashboard/_statistics.blade.php b/resources/views/dashboard/_statistics.blade.php index d9f68cae..23e91e1a 100644 --- a/resources/views/dashboard/_statistics.blade.php +++ b/resources/views/dashboard/_statistics.blade.php @@ -20,6 +20,15 @@ $fmt = fn ($value) => number_format((float) $value, 0, ',', '.'); + // Solange die Monatsqualifikation aussteht, zaehlen die beiden + // Payline-Struktur-Kacheln nach den Ebenen des Karriere-Levels — das + // wird an der Kachel ausgewiesen, damit die Zahl einordenbar bleibt. + $paylineFallbackNote = ! empty($s['payline_is_career_fallback']) + ? __('home.payline_career_fallback_hint') + : null; + + $commissionFailed = (bool) ($s['commission']['failed'] ?? false); + $clusters = [ [ 'title' => __('home.stats_group_turnover'), @@ -39,9 +48,15 @@ ], [ 'label' => __('home.commission_today'), - 'value' => number_format((float) ($s['commission']['total'] ?? 0), 2, ',', '.') . ' €', - 'icon' => 'ion-md-cash', 'color' => 'warning', 'link' => null, 'new' => null, - 'live' => (bool) ($s['commission']['is_live'] ?? false), + // Eine fehlgeschlagene Berechnung darf nicht wie ein Nullmonat aussehen. + 'value' => $commissionFailed + ? '—' + : number_format((float) ($s['commission']['total'] ?? 0), 2, ',', '.') . ' €', + 'icon' => 'ion-md-cash', + 'color' => $commissionFailed ? 'secondary' : 'warning', + 'link' => null, 'new' => null, + 'live' => ! $commissionFailed && (bool) ($s['commission']['is_live'] ?? false), + 'note' => $commissionFailed ? __('home.commission_unavailable') : null, ], ], ], @@ -75,11 +90,13 @@ [ 'label' => __('home.payline_partner_count'), 'value' => $fmt($s['payline_member_count'] ?? 0), 'icon' => 'ion-md-podium', 'color' => 'success', 'link' => null, 'new' => null, 'live' => false, + 'note' => $paylineFallbackNote, ], [ 'label' => __('home.payline_abo_count'), 'value' => $fmt($s['payline_abo_count'] ?? 0), 'icon' => 'ion-md-repeat', 'color' => 'success', 'link' => $teamShow, 'new' => $s['payline_abos_new'] ?? 0, 'live' => false, + 'note' => $paylineFallbackNote, ], ], ], @@ -160,6 +177,10 @@ @if ($tile['link']) @endif + @if (! empty($tile['note'])) + + @endif
{{ $tile['value'] }} @@ -195,20 +216,34 @@
- - {{ $level['name'] }} + @php($isQualified = $level['is_qualified'] ?? true) + + {{ $isQualified ? $level['name'] : __('home.level_not_qualified_yet') }}
@if ($level['next_name'] !== null && $level['next_name'] !== '') -
- {!! __('home.level_next_missing', [ - 'level' => ''.e($level['next_name']).'', - 'points' => ''.$fmt($level['missing_pp']).'', - ]) !!} -
- @if ($level['missing_personal'] > 0) -
- {{ __('home.level_next_missing_personal', ['points' => $fmt($level['missing_personal'])]) }} + {{-- Ohne Monatsquali fehlen typischerweise nur persoenliche Punkte + (das erste Level hat qual_pp = 0) — dann darf hier kein Satz + mit "0 Payline-Punkte" stehen. --}} + @if ($level['missing_pp'] > 0) +
+ {!! __('home.level_next_missing', [ + 'level' => ''.e($level['next_name']).'', + 'points' => ''.$fmt($level['missing_pp']).'', + ]) !!} +
+ @if ($level['missing_personal'] > 0) +
+ {{ __('home.level_next_missing_personal', ['points' => $fmt($level['missing_personal'])]) }} +
+ @endif + @elseif ($level['missing_personal'] > 0) +
+ {!! __('home.level_next_missing_personal_only', [ + 'level' => ''.e($level['next_name']).'', + 'points' => ''.$fmt($level['missing_personal']).'', + ]) !!}
@endif @else @@ -228,6 +263,9 @@ Live + {{-- Woraus die Basis der vier Kacheln besteht (Kundenwunsch 2026-09-07): + HomeController uebergibt customer_points + team_payline_points. --}} +
{{ __('home.payline_forecast_basis') }}
@foreach ($s['payline_forecast']['days'] as $forecastDay)
@@ -281,12 +319,28 @@ @endif @endif
- {!! __('home.forecast_commission', [ - 'amount' => ''.number_format((float) $lf['estimated_commission'], 2, ',', '.').' €', - 'level' => e($lf['estimated_level_name']), - ]) !!} + @if ($lf['estimated_level_name'] !== '') + {!! __('home.forecast_commission', [ + 'amount' => ''.number_format((float) $lf['estimated_commission'], 2, ',', '.').' €', + 'level' => e($lf['estimated_level_name']), + ]) !!} + @else + {{-- Noch keine Stufe erreicht: die Prognose nennt einen Betrag, + aber keine Stufe, auf deren Saetzen er beruht. --}} + {!! __('home.forecast_commission_no_level', [ + 'amount' => ''.number_format((float) $lf['estimated_commission'], 2, ',', '.').' €', + ]) !!} + @endif
+ @elseif (! empty($s['level']) && ! ($s['level']['is_qualified'] ?? true)) + {{-- Ohne Qualifikationsstufe gibt es keine Provisionssaetze, mit denen + sich eine Prognose rechnen liesse. Statt den Block wortlos + wegzulassen, wird der Grund genannt. --}} +
+ + {{ __('home.forecast_level_needs_qualification') }} +
@endif @endif
diff --git a/resources/views/user/abo/_customer_abo_list.blade.php b/resources/views/user/abo/_customer_abo_list.blade.php new file mode 100644 index 00000000..76e09199 --- /dev/null +++ b/resources/views/user/abo/_customer_abo_list.blade.php @@ -0,0 +1,110 @@ +{{-- Eigene Kundenabos als sortierbare Liste (Kundenwunsch 2026-09-07: die + Kartenansicht war bei vielen Abos unübersichtlich). Spalten und Sortierung + nach ihrer Vorgabe: Kunde, Liefertag, Abo-Punkte, nächste Ausführung, + Startdatum, Ausführungen. + + Erwartet $listAbos (Collection) und $customerAboForecast|null. --}} +@if (! empty($customerAboForecast['days'])) +
+
+
+ {{ __('abo.customer_abo_forecast_title') }} +
+
{{ __('abo.customer_abo_forecast_hint') }}
+
+ @foreach ($customerAboForecast['days'] as $forecastDay) +
+
+
+
+ {{ __('home.forecast_day', ['date' => $forecastDay['date']]) }} +
+
+ {{ \App\Services\Util::formatNumber($forecastDay['total']) }} +
+
+ {{ $forecastDay['is_past'] && $forecastDay['upcoming'] <= 0 ? __('home.forecast_done') : __('navigation.points') }} +
+
+
+
+ @endforeach +
+
+
+@endif + +
+
+
+
+
{{ __('abo.customer_abo_list_title', ['count' => $listAbos->count()]) }}
+
+
+
+
+ +
+ +
+
+
+
+
+
+ + + + + + + + + + + + + + @foreach ($listAbos as $listAbo) + + @php + // Sortiert wird über data-sort-value, nicht über den + // angezeigten Text — der ist lokalisiert (A14). Der Name + // steht zusätzlich getrennt da, weil die Zelle auch die + // E-Mail enthält und sonst beides zusammen sortiert würde. + $listAboName = $listAbo->shopping_user + ? trim($listAbo->shopping_user->billing_firstname.' '.$listAbo->shopping_user->billing_lastname) + : (string) $listAbo->email; + @endphp + + + + + + + + + + @endforeach + +
{{ __('tables.customer') }} {{ __('tables.abo_delivery_day') }} {{ __('navigation.points') }} {{ __('tables.next_date') }} {{ __('tables.start_date') }} {{ __('tables.abo_delivery') }} {{ __('tables.status') }}
+
+ {{ $listAboName }} +
+ @if ($listAbo->shopping_user) +
{{ $listAbo->shopping_user->billing_email }}
+ @endif +
{{ \App\Services\HTMLHelper::getAboStrLang($listAbo->abo_interval) }}{{ $listAbo->getFormattedTotalPoints() }}{{ $listAbo->next_date }}{{ $listAbo->start_date }}{{ $listAbo->getCountPaidOrders() }}{!! $listAbo->getStatusFormated() !!} {!! get_active_badge($listAbo->active) !!} + + {{ __('abo.abo_details') }} + +
+ + + +@include('components._sortable_table', [ + 'tableId' => 'customer-abo-table', + 'searchId' => 'customer-abo-search', + 'minWidth' => '900px', +]) diff --git a/resources/views/user/backoffice/statistics/details.blade.php b/resources/views/user/backoffice/statistics/details.blade.php index 20435003..ab4f6109 100644 --- a/resources/views/user/backoffice/statistics/details.blade.php +++ b/resources/views/user/backoffice/statistics/details.blade.php @@ -100,9 +100,10 @@ Kundenpunkte Gesamtpunkte @elseif ($details['metric'] === 'new_partners') + Telefon Sponsor - Kundenpunkte - Punkte gesamt + Punkte seiner Kunden + Gesamt (inkl. Eigenbestellung) Aktiv seit Account gültig bis @else @@ -119,8 +120,8 @@ {{ $row['email'] ?? '-' }} {{ $row['career_level'] ?? '-' }} @if ($details['metric'] === 'team_customer_abos') - {{ number_format($row['abo_count'], 0, ',', '.') }} - + {{ number_format($row['abo_count'], 0, ',', '.') }} + {{ \App\Services\Util::formatNumber($row['points']) }} @if (! empty($row['abos'])) @elseif ($details['metric'] === 'team_partner_abos') {{ $row['consultant_name'] ?? $row['name'] }} - {{ \App\Services\Util::formatNumber($row['points']) }} - + {{ \App\Services\Util::formatNumber($row['points']) }} + {!! $row['status_badge'] ?? '' !!} @if (! empty($row['status_reason']))
{{ $row['status_reason'] }}
@endif - + {{ $row['start_date'] ?? '-' }} @if (! empty($row['is_new_this_month'])) Neu @endif - {{ $row['next_date'] ?? '-' }} - {{ number_format($row['deliveries'], 0, ',', '.') }} + {{ $row['next_date'] ?? '-' }} + {{ number_format($row['deliveries'], 0, ',', '.') }} @elseif (in_array($details['metric'], ['own_points', 'external_points', 'customer_abo_points', 'customer_single_order_points', 'customer_other_points', 'total_points', 'shop_1000'], true)) - {{ \App\Services\Util::formatNumber($row['own_points']) }} - {{ \App\Services\Util::formatNumber($row['customer_single_order_points']) }} - {{ \App\Services\Util::formatNumber($row['customer_abo_points']) }} - {{ \App\Services\Util::formatNumber($row['customer_other_points']) }} - {{ \App\Services\Util::formatNumber($row['external_points']) }} - {{ \App\Services\Util::formatNumber($row['total_points']) }} + {{ \App\Services\Util::formatNumber($row['own_points']) }} + {{ \App\Services\Util::formatNumber($row['customer_single_order_points']) }} + {{ \App\Services\Util::formatNumber($row['customer_abo_points']) }} + {{ \App\Services\Util::formatNumber($row['customer_other_points']) }} + {{ \App\Services\Util::formatNumber($row['external_points']) }} + {{ \App\Services\Util::formatNumber($row['total_points']) }} @elseif ($details['metric'] === 'new_partners') + {{ ($row['phone'] ?? '') !== '' ? $row['phone'] : '-' }} {{ $row['sponsor'] ?? '-' }} - {{ \App\Services\Util::formatNumber($row['customer_points'] ?? 0) }} - {{ \App\Services\Util::formatNumber($row['total_points'] ?? 0) }} - {{ $row['active_date'] ?? '-' }} - {{ $row['payment_account'] ?? '-' }} + {{ \App\Services\Util::formatNumber($row['customer_points'] ?? 0) }} + {{ \App\Services\Util::formatNumber($row['total_points'] ?? 0) }} + {{ $row['active_date'] ?? '-' }} + {{ $row['payment_account'] ?? '-' }} @else - {{ $row['active_date'] ?? '-' }} - + {{ $row['active_date'] ?? '-' }} + {{ $row['payment_account'] ?? '-' }} @if (isset($row['is_account_active'])) {{ \App\Services\Util::formatNumber($details['summary']['external_points']) }} {{ \App\Services\Util::formatNumber($details['summary']['total_points']) }} @elseif ($details['metric'] === 'new_partners') + {{ \App\Services\Util::formatNumber($details['summary']['customer_points'] ?? 0) }} {{ \App\Services\Util::formatNumber($details['summary']['total_points'] ?? 0) }} @@ -229,104 +232,9 @@ @if ($details['rows'] !== []) - - - + @include('components._sortable_table', [ + 'tableId' => 'backoffice-statistics-detail-table', + 'searchId' => 'backoffice-statistics-detail-search', + ]) @endif @endsection diff --git a/tests/Feature/AboLockDaysSettingTest.php b/tests/Feature/AboLockDaysSettingTest.php index bd23deaa..fcefdf03 100644 --- a/tests/Feature/AboLockDaysSettingTest.php +++ b/tests/Feature/AboLockDaysSettingTest.php @@ -9,8 +9,9 @@ use App\Services\AboHelper; use App\User; use Carbon\Carbon; use Illuminate\Foundation\Testing\RefreshDatabase; +use Tests\TestCase; -uses(Tests\TestCase::class, RefreshDatabase::class); +uses(TestCase::class, RefreshDatabase::class); afterEach(fn () => Carbon::setTestNow()); @@ -91,25 +92,29 @@ describe('Abo-Sperrfristen als Setting', function () { ->and(session('alert-error'))->not->toContain('mindestens 10 Tage'); }); - it('erlaubt Pausieren im Change-Lock-Fenster, wenn außerhalb der Pause-Sperre (Intervall unverändert)', function () { + it('erlaubt Pausieren im Change-Lock-Fenster, wenn außerhalb der Pause-Sperre', function () { Carbon::setTestNow('2026-03-19 00:00:00'); + Setting::setContentBySlug('abo-min-duration', 0, 'int'); // 5 Tage entfernt: innerhalb Change-Lock (10), außerhalb Pause-Lock (3). $abo = makeLockTestAbo(['abo_interval' => 20, 'next_date' => '2026-03-24', 'active' => true, 'status' => 2]); $this->actingAs($abo->user); $repository = new AboRepository; $repository->setModel($abo); - // Intervall unverändert (20), kein abo_is_active => Pausieren. $result = $repository->update([ 'action' => 'abo_update_settings', 'id' => $abo->id, 'view' => 'me', 'abo_interval' => 20, + 'abo_pause' => 'true', ]); + // Die Pause verschiebt nur den Liefertermin auf den Liefertag des Folgemonats; + // das Abo bleibt aktiv. expect($result)->toBeTrue() - ->and($abo->refresh()->active)->toBeFalse() - ->and($abo->status)->toBe(6); + ->and($abo->refresh()->active)->toBeTrue() + ->and($abo->status)->toBe(2) + ->and($abo->getRawOriginal('next_date'))->toBe('2026-04-20'); }); }); diff --git a/tests/Feature/AboStatusGuardTest.php b/tests/Feature/AboStatusGuardTest.php new file mode 100644 index 00000000..17866653 --- /dev/null +++ b/tests/Feature/AboStatusGuardTest.php @@ -0,0 +1,246 @@ + Carbon::setTestNow('2026-03-09 10:00:00')); +afterEach(fn () => Carbon::setTestNow()); + +/** + * A01 — Der Aktiv-Zustand darf sich im Selbstbedienungspfad nur über die dafür + * zugelassenen Aktionen ändern. Das Formular sendet ihn als verstecktes Feld mit; + * ein manipulierter Request könnte die Mindestlaufzeit sonst durch dauerhafte + * Deaktivierung umgehen, statt den Ein-Zyklus-Pausenpfad mit seinen Sperren zu + * benutzen. + */ +describe('A01: Aktiv-Schalter im Selbstbedienungspfad', function () { + it('deaktiviert das Abo nicht, wenn das Aktiv-Feld auf 0 gesetzt wird', function (string $view) { + $abo = makeStatusGuardAbo(['is_for' => $view === 'ot' ? 'ot' : 'me']); + $this->actingAs($abo->user); + + $result = statusGuardUpdate($abo, [ + 'view' => $view, + 'abo_is_active' => '0', + ]); + + expect($result)->toBeTrue() + ->and($abo->refresh()->active)->toBeTrue() + ->and($abo->status)->toBe(2) + ->and($abo->status_histories()->count())->toBe(0); + })->with(['me', 'ot', 'portal']); + + it('deaktiviert das Abo auch dann nicht, wenn das Aktiv-Feld ganz fehlt', function () { + $abo = makeStatusGuardAbo(); + $this->actingAs($abo->user); + + statusGuardUpdate($abo, ['view' => 'me']); + + expect($abo->refresh()->active)->toBeTrue() + ->and($abo->status)->toBe(2); + }); + + it('reaktiviert ein deaktiviertes Abo nicht über das Aktiv-Feld', function () { + $abo = makeStatusGuardAbo(['active' => false, 'status' => 6]); + $this->actingAs($abo->user); + + statusGuardUpdate($abo, [ + 'view' => 'me', + 'abo_is_active' => 'true', + ]); + + expect($abo->refresh()->active)->toBeFalse() + ->and($abo->status)->toBe(6); + }); + + it('lässt wiederholte Umschaltversuche wirkungslos', function () { + $abo = makeStatusGuardAbo(); + $this->actingAs($abo->user); + + foreach (['0', 'true', '0'] as $value) { + statusGuardUpdate($abo, ['view' => 'me', 'abo_is_active' => $value]); + } + + expect($abo->refresh()->active)->toBeTrue() + ->and($abo->status_histories()->count())->toBe(0); + }); + + it('setzt die Mindestlaufzeit auch bei manipuliertem Aktiv-Feld durch', function () { + Setting::setContentBySlug('abo-min-duration', 3, 'int'); + $abo = makeStatusGuardAbo(); + $this->actingAs($abo->user); + + // Ohne bezahlte Lieferungen ist weder Kündigung noch Pause erlaubt … + expect(AboHelper::canCancelAbo($abo, 'me'))->toBeFalse(); + + // … und der Umweg über das Aktiv-Feld führt ebenfalls nicht zum Ziel. + statusGuardUpdate($abo, ['view' => 'me', 'abo_is_active' => '0']); + + expect($abo->refresh()->active)->toBeTrue(); + }); + + it('lässt den Admin weiterhin de- und reaktivieren', function () { + $abo = makeStatusGuardAbo(); + $admin = User::forceCreate([ + 'email' => 'status-guard-admin-'.uniqid('', true).'@example.com', + 'password' => bcrypt('secret'), + 'lang' => 'de', + 'admin' => 4, + ]); + $this->actingAs($admin); + + statusGuardUpdate($abo, ['view' => 'admin', 'abo_next_month' => '2026-03']); + expect($abo->refresh()->active)->toBeFalse() + ->and($abo->status)->toBe(6); + + statusGuardUpdate($abo, ['view' => 'admin', 'abo_next_month' => '2026-03', 'abo_is_active' => 'true']); + expect($abo->refresh()->active)->toBeTrue() + ->and($abo->status)->toBe(2); + }); +}); + +/** + * A16 — Jeder Wechsel des Aktiv-Zustands muss protokolliert werden, egal welcher + * Schreibpfad ihn auslöst. Die historische Bestandsrückrechnung verwirft ein Abo, + * solange eine protokollierte Deaktivierung nicht durch eine spätere Reaktivierung + * aufgehoben ist. + */ +describe('A16: Statushistorie über alle Schreibpfade', function () { + it('protokolliert Deaktivierung und Reaktivierung aus dem Admin-Formular mit Kanal und Akteur', function () { + $abo = makeStatusGuardAbo(); + $admin = User::forceCreate([ + 'email' => 'status-guard-admin-'.uniqid('', true).'@example.com', + 'password' => bcrypt('secret'), + 'lang' => 'de', + 'admin' => 4, + ]); + $this->actingAs($admin); + + statusGuardUpdate($abo, ['view' => 'admin', 'abo_next_month' => '2026-03']); + statusGuardUpdate($abo, ['view' => 'admin', 'abo_next_month' => '2026-03', 'abo_is_active' => 'true']); + + $entries = $abo->status_histories()->orderBy('id')->get(); + + expect($entries->pluck('action')->all())->toBe([ + UserAboStatusHistory::ACTION_DEACTIVATED, + UserAboStatusHistory::ACTION_REACTIVATED, + ]) + ->and($entries->pluck('view')->unique()->all())->toBe(['admin']) + ->and($entries->pluck('actor_user_id')->unique()->all())->toBe([$admin->id]); + }); + + it('protokolliert die Kündigung aus dem Selbstbedienungspfad', function () { + Setting::setContentBySlug('abo-min-duration', 0, 'int'); + $abo = makeStatusGuardAbo(['next_date' => '2026-04-20']); + $this->actingAs($abo->user); + + statusGuardUpdate($abo, ['view' => 'me', 'abo_cancel' => 'true']); + + $entry = $abo->status_histories()->sole(); + + expect($abo->refresh()->active)->toBeFalse() + ->and($abo->status)->toBe(4) + ->and($entry->action)->toBe(UserAboStatusHistory::ACTION_CANCELLED) + ->and($entry->view)->toBe('me'); + }); + + it('protokolliert eine Reaktivierung aus einem Systemschreibpfad ohne Formularkanal', function () { + // Stellvertretend für Recovery-Zahlung, Retry und externe Erledigung: alle + // schreiben status=2/active=true direkt auf das Modell. + $abo = makeStatusGuardAbo(['active' => false, 'status' => 6]); + + $abo->update(['status' => 2, 'active' => true]); + + $entry = $abo->status_histories()->sole(); + + expect($entry->action)->toBe(UserAboStatusHistory::ACTION_REACTIVATED) + ->and($entry->view)->toBeNull() + ->and($entry->actor_user_id)->toBeNull(); + }); + + it('schreibt keinen Eintrag, wenn der Aktiv-Zustand unverändert bleibt', function () { + $abo = makeStatusGuardAbo(); + + $abo->update(['status' => 2, 'active' => true, 'last_date' => now()]); + + expect($abo->status_histories()->count())->toBe(0); + }); + + it('protokolliert nicht beim Löschen — dafür wertet die Rückrechnung deleted_at aus', function () { + $abo = makeStatusGuardAbo(); + + $abo->delete(); + + expect($abo->status_histories()->count())->toBe(0) + ->and(UserAbo::withTrashed()->find($abo->id)->active)->toBeFalse(); + }); +}); + +/** + * @param array $data + */ +function statusGuardUpdate(UserAbo $abo, array $data): mixed +{ + $repository = new AboRepository; + $repository->setModel($abo); + + return $repository->update(array_merge([ + 'action' => 'abo_update_settings', + 'id' => $abo->id, + 'abo_interval' => $abo->abo_interval, + ], $data)); +} + +/** + * @param array $overrides + */ +function makeStatusGuardAbo(array $overrides = []): UserAbo +{ + $country = Country::create([ + 'code' => 'DE', 'phone' => '49', 'en' => 'Germany', 'de' => 'Deutschland', + 'es' => 'Alemania', 'fr' => 'Allemagne', 'it' => 'Germania', 'ru' => 'Deutschland', + ]); + + $user = User::forceCreate([ + 'email' => 'status-guard-'.uniqid('', true).'@example.com', + 'password' => bcrypt('secret'), + 'lang' => 'de', + 'admin' => 0, + ]); + + $shoppingUser = ShoppingUser::create([ + 'auth_user_id' => $user->id, + 'member_id' => $user->id, + 'billing_country_id' => $country->id, + 'shipping_country_id' => $country->id, + 'billing_email' => $user->email, + 'is_for' => 'me', + 'is_from' => 'user_order', + ]); + + return UserAbo::create(array_merge([ + 'user_id' => $user->id, + 'member_id' => $user->id, + 'shopping_user_id' => $shoppingUser->id, + 'is_for' => 'me', + 'email' => $user->email, + 'payone_userid' => random_int(100000, 999999), + 'clearingtype' => 'cc', + 'active' => true, + 'status' => 2, + 'abo_interval' => 20, + 'start_date' => '2026-01-20', + 'last_date' => '2026-02-20', + 'next_date' => '2026-03-20', + ], $overrides)); +} diff --git a/tests/Feature/BackofficeStatisticsDrilldownTest.php b/tests/Feature/BackofficeStatisticsDrilldownTest.php index 3ef17cf3..d2709be1 100644 --- a/tests/Feature/BackofficeStatisticsDrilldownTest.php +++ b/tests/Feature/BackofficeStatisticsDrilldownTest.php @@ -3,6 +3,7 @@ use App\Models\Product; use App\Models\UserAbo; use App\Models\UserAboItem; +use App\Models\UserAccount; use App\Models\UserSalesVolume; use App\Services\Abo\TeamAboOverviewService; use App\Services\Backoffice\BackofficeDashboardService; @@ -270,3 +271,67 @@ it('filtert Abo-Drilldowns mit only_new auf die im Monat neuen Abos', function ( expect($onlyNewCustomers['rows'])->toHaveCount(1) ->and($onlyNewCustomers['rows'][0]['abo_count'])->toBe(1); }); + +it('liefert im Neupartner-Drilldown die Telefonnummer mit Mobil-Fallback', function (): void { + // Kundenwunsch 2026-09-07: zum Nachfassen gehoert die Telefonnummer in die Liste. + $root = boMakeUser(); + + $mitFestnetz = boMakeUser($root->id, '2026-06-05'); + $mitFestnetz->forceFill(['account_id' => UserAccount::forceCreate([ + 'first_name' => 'Fritz', 'last_name' => 'Festnetz', 'phone' => '0301234', 'mobil' => '01700000', + ])->id])->save(); + + $nurMobil = boMakeUser($root->id, '2026-06-06'); + $nurMobil->forceFill(['account_id' => UserAccount::forceCreate([ + 'first_name' => 'Mona', 'last_name' => 'Mobil', 'phone' => '', 'mobil' => '01711111', + ])->id])->save(); + + $ohne = boMakeUser($root->id, '2026-06-07'); + + $rows = collect(app(BackofficeDrilldownService::class)->details($root, 0, 'new_partners', 6, 2026)['rows']) + ->keyBy('user_id'); + + expect($rows->get($mitFestnetz->id)['phone'])->toBe('0301234') + ->and($rows->get($nurMobil->id)['phone'])->toBe('01711111') + ->and($rows->get($ohne->id)['phone'])->toBe(''); +}); + +it('liefert Sortierwerte als ISO-Datum, unabhaengig von der Anzeigesprache (A14)', function (string $locale): void { + app()->setLocale($locale); + + $root = boMakeUser(); + $anna = boMakeUser($root->id, '2026-03-15'); + boMakeAbo($anna->id, 'me', '2026-06-10', 20); + + $service = app(BackofficeDrilldownService::class); + + $abo = $service->details($root, 0, 'team_partner_abos', 6, 2026)['rows'][0]; + expect($abo['start_date_sort'])->toBe('2026-06-10') + ->and($abo['next_date_sort'])->toBe('2026-12-10'); + + $consultant = collect($service->details($root, 0, 'consultants', 6, 2026)['rows']) + ->firstWhere('user_id', $anna->id); + expect($consultant['active_date_sort'])->toBe('2026-03-15'); +})->with(['de', 'en']); + +it('haengt die Sortierwerte an die Zellen der Drilldown-Tabelle (A14)', function (): void { + URL::resolveMissingNamedRoutesUsing(fn (string $name, $parameters = []) => '/'.$name); + + $root = boMakeUser(); + $anna = boMakeUser($root->id, '2026-03-15'); + boMakeAbo($anna->id, 'me', '2026-06-10', 20); + $this->actingAs($root); + + $details = app(BackofficeDrilldownService::class)->details($root, 0, 'team_partner_abos', 6, 2026); + + $html = view('user.backoffice.statistics.details', [ + 'details' => $details, + 'selectedMonth' => 6, + 'selectedYear' => 2026, + ])->render(); + + expect($html) + ->toContain('data-sort-value="2026-06-10"') + ->toContain('data-sort-value="2026-12-10"') + ->toContain('data-sort-value="20"'); +}); diff --git a/tests/Feature/CustomerAboListViewTest.php b/tests/Feature/CustomerAboListViewTest.php new file mode 100644 index 00000000..c61bbcf6 --- /dev/null +++ b/tests/Feature/CustomerAboListViewTest.php @@ -0,0 +1,137 @@ + '/'.$name); +}); + +afterEach(function () { + Carbon::setTestNow(); +}); + +function calMakeAbo(int $memberId, string $startDate, string $nextDate, int $interval = 5): UserAbo +{ + return UserAbo::create([ + 'user_id' => $memberId, + 'member_id' => $memberId, + 'shopping_user_id' => 0, + 'email' => 'cal-'.uniqid('', true).'@example.com', + 'is_for' => 'ot', + 'payone_userid' => 0, + 'clearingtype' => 'cc', + 'amount' => 5000, + 'active' => true, + 'status' => 2, + 'abo_interval' => $interval, + 'start_date' => $startDate, + 'next_date' => $nextDate, + ]); +} + +it('rendert die Kundenabo-Liste mit allen gewuenschten Spalten', function (): void { + // Kundenwunsch 2026-09-07: Name, Liefertag, Abo-Punkte, naechste Ausfuehrung, + // Startdatum, Ausfuehrungen — sortierbar statt als Karten. + $user = User::forceCreate([ + 'email' => 'cal-owner@example.com', + 'password' => bcrypt('secret'), + 'lang' => 'de', + 'm_level' => 1, + 'payment_account' => now()->addYear(), + ]); + + $abo = calMakeAbo($user->id, '2026-04-20', '2026-09-20', 20); + + $html = view('user.abo._customer_abo_list', [ + 'listAbos' => collect([$abo]), + 'customerAboForecast' => ['base' => 0.0, 'days' => [ + ['day' => 5, 'date' => '05.09.2026', 'upcoming' => 0.0, 'total' => 0.0, 'is_past' => true], + ['day' => 10, 'date' => '10.09.2026', 'upcoming' => 36.0, 'total' => 36.0, 'is_past' => false], + ['day' => 20, 'date' => '20.09.2026', 'upcoming' => 81.0, 'total' => 81.0, 'is_past' => false], + ['day' => 25, 'date' => '25.09.2026', 'upcoming' => 81.0, 'total' => 81.0, 'is_past' => false], + ]], + ])->render(); + + expect($html) + ->toContain(e(__('tables.abo_delivery_day'))) + ->toContain(e(__('tables.next_date'))) + ->toContain(e(__('tables.start_date'))) + ->toContain(e(__('tables.abo_delivery'))) + // Sortier-Partial ist eingebunden und haengt an der richtigen Tabelle + ->toContain('customer-abo-table') + ->toContain('data-sortable="true"') + // Liefertag-Prognose aus den eigenen Kundenabos + ->toContain('81') + ->toContain(e(__('abo.customer_abo_forecast_title'))); +}); + +it('rendert die Liste auch ohne Liefertag-Prognose', function (): void { + // Fuer vergangene Monate uebergibt der Controller null — die Tabelle bleibt. + $user = User::forceCreate([ + 'email' => 'cal-owner2@example.com', + 'password' => bcrypt('secret'), + 'lang' => 'de', + 'm_level' => 1, + 'payment_account' => now()->addYear(), + ]); + + $html = view('user.abo._customer_abo_list', [ + 'listAbos' => collect([calMakeAbo($user->id, '2026-04-20', '2026-09-20')]), + 'customerAboForecast' => null, + ])->render(); + + expect($html)->toContain('customer-abo-table') + ->not->toContain(e(__('abo.customer_abo_forecast_title'))); +}); + +it('kompiliert die Abo-Seite vollstaendig, ohne Blade-Direktiven zu verschlucken', function (string $view): void { + // Regression 2026-09-07: Wird die Ausdrucksform @php(...) mit einem + // @php ... @endphp-Block in derselben Datei gemischt, matcht Blades + // storePhpBlocks vom ersten @php bis zum ersten @endphp und schluckt + // alles dazwischen — dann fehlen Zuweisungen wie $cardAbos zur Laufzeit. + $source = file_get_contents(resource_path('views/'.str_replace('.', '/', $view).'.blade.php')); + + $compiled = app('blade.compiler')->compileString($source); + + expect($compiled)->not->toMatch('/@(if|unless|foreach|forelse|php|endif|endforeach|endphp)\b/'); +})->with([ + 'user.abo.index', + 'user.abo._customer_abo_list', + 'dashboard._statistics', + 'user.team.firstlines', + 'user.team.points', + 'user.team.abos', + 'components._sortable_table', +]); + +it('gibt unformatierte Sortierwerte aus, unabhaengig von der Sprache (A14)', function (string $locale): void { + // Der Zellentext ist lokalisiert (en: `Y-m-d` und `1,000.00`), der + // Sortierschluessel nicht — sonst sortiert die Tabelle in en falsch. + app()->setLocale($locale); + + $user = User::forceCreate([ + 'email' => 'cal-sort-'.$locale.'@example.com', + 'password' => bcrypt('secret'), + 'lang' => $locale, + 'm_level' => 1, + 'payment_account' => now()->addYear(), + ]); + + $html = view('user.abo._customer_abo_list', [ + 'listAbos' => collect([calMakeAbo($user->id, '2026-04-05', '2026-09-20', 20)]), + 'customerAboForecast' => null, + ])->render(); + + expect($html) + ->toContain('data-sort-value="2026-09-20"') // naechste Ausfuehrung + ->toContain('data-sort-value="2026-04-05"') // Startdatum + ->toContain('data-sort-value="20"'); // Liefertag +})->with(['de', 'en']); diff --git a/tests/Feature/HomeMonthlyStatsTest.php b/tests/Feature/HomeMonthlyStatsTest.php index a62f5bb7..b2057d59 100644 --- a/tests/Feature/HomeMonthlyStatsTest.php +++ b/tests/Feature/HomeMonthlyStatsTest.php @@ -1,6 +1,10 @@ 8, 'stats_year' => 2026]); + Request::merge(['stats_month' => $month, 'stats_year' => $year]); $method = (new ReflectionClass(HomeController::class))->getMethod('buildMonthlyStats'); $method->setAccessible(true); @@ -75,3 +79,119 @@ it('zaehlt Neupartner weiterhin ueber alle Firstlines, nicht nur ueber die Kache expect($stats['direct_new_partners'])->toBe(1) ->and($stats['firstline_count'])->toBe(2); }); + +/** + * Legt ein Karriere-Level an und haengt den User daran. Ohne Monatsqualifikation + * ist genau das die Ebenenzahl, mit der die beiden Payline-Kacheln rechnen. + */ +function hmsMakeLevel(int $pos, int $paylines, string $name = 'Testlevel'): UserLevel +{ + return UserLevel::forceCreate([ + 'name' => $name.' '.$pos, + 'qual_kp' => 150, + 'qual_pp' => 0, + 'paylines' => $paylines, + 'pos' => $pos, + 'active' => true, + ]); +} + +it('zeigt die Payline-Kacheln am Monatsanfang nach dem Karriere-Level, statt auf 0 zu fallen', function (): void { + // Ohne Monatsquali liefert der Live-Lauf paylines = 0. Die beiden + // Struktur-Kacheln muessen dann auf die Ebenen des Karriere-Levels + // zurueckfallen — die Teamstruktur aendert sich zum Monatswechsel nicht. + $level = hmsMakeLevel(pos: 3, paylines: 2); + + $root = hmsMakeUser(); + $root->forceFill(['m_level' => $level->id])->save(); + + $line1 = hmsMakeUser($root->id); + $line2 = hmsMakeUser($line1->id); + // Ebene 3 liegt ausserhalb der zwei bezahlten Ebenen des Karriere-Levels. + hmsMakeUser($line2->id); + + $stats = hmsMonthlyStats($root->fresh()); + + expect($stats['payline_is_career_fallback'])->toBeTrue() + ->and($stats['payline_member_count'])->toBe(2) + ->and($stats['team_partner_count'])->toBe(3); +}); + +it('haelt eine leere Payline nicht im 6-h-Cache fest', function (): void { + // Ohne Karriere-Level bleibt die Tiefe 0. Das darf NICHT gecacht werden: + // der Zustand loest sich im Monatsverlauf von selbst auf. + $root = hmsMakeUser(); + hmsMakeUser($root->id); + + $service = app(PaylineForecastService::class); + $context = $service->paylineContext($root, 8, 2026); + + expect($context['paylines'])->toBe(0) + ->and($context['member_ids'])->toBe([]) + ->and(Cache::has("payline_context_{$root->id}_8_2026"))->toBeFalse(); +}); + +it('cacht den Payline-Kontext, sobald eine Ebenenzahl feststeht', function (): void { + $level = hmsMakeLevel(pos: 3, paylines: 2); + + $root = hmsMakeUser(); + $root->forceFill(['m_level' => $level->id])->save(); + hmsMakeUser($root->id); + + $service = app(PaylineForecastService::class); + $context = $service->paylineContext($root->fresh(), 8, 2026); + + expect($context['paylines'])->toBe(2) + ->and(Cache::has("payline_context_{$root->id}_8_2026"))->toBeTrue(); +}); + +it('blendet den Level-Block ohne Monatsqualifikation nicht aus, sondern zeigt die fehlenden Punkte', function (): void { + // setFirstQualLevel() liefert das Level auf pos 1 als next_level mit — + // daraus muss "dir fehlen noch X Punkte persoenliches Volumen" entstehen. + hmsMakeLevel(pos: 1, paylines: 3, name: 'Junior Berater'); + + $root = hmsMakeUser(); + $root->forceFill(['m_level' => UserLevel::where('pos', 1)->value('id')])->save(); + + $level = app(LiveCommissionService::class) + ->levelProgress($root->fresh(), 8, 2026); + + expect($level)->not->toBeNull() + ->and($level['is_qualified'])->toBeFalse() + ->and($level['name'])->toBe('') + ->and($level['next_name'])->toBe('Junior Berater 1') + ->and($level['missing_personal'])->toBe(150.0); +}); + +it('rechnet Bestand und "+N neu" der Payline-Kachel nach derselben Regel', function (): void { + // Regression: der Bestand kam aus der Monatsend-Rueckrechnung, das Badge aus + // activeAbo(). In derselben Kachel standen damit zwei Zaehlweisen. + $level = hmsMakeLevel(pos: 3, paylines: 2); + + $root = hmsMakeUser(); + $root->forceFill(['m_level' => $level->id])->save(); + $anna = hmsMakeUser($root->id); + + $abo = UserAbo::create([ + 'user_id' => $anna->id, + 'member_id' => $anna->id, + 'shopping_user_id' => 0, + 'email' => 'hms-abo@example.com', + 'is_for' => 'ot', + 'payone_userid' => 0, + 'clearingtype' => 'cc', + 'amount' => 5000, + 'active' => false, + 'status' => 2, + 'abo_interval' => 5, + 'start_date' => '2026-07-10', + 'next_date' => '2026-08-25', + ]); + // Erst im August beendet -> zaehlt im Juli sowohl im Bestand als auch als "neu". + $abo->update(['cancel_date' => '2026-08-05']); + + $stats = hmsMonthlyStats($root->fresh(), month: 7, year: 2026); + + expect($stats['payline_abo_count'])->toBe(1) + ->and($stats['payline_abos_new'])->toBe(1); +}); diff --git a/tests/Feature/LiveCommissionServiceTest.php b/tests/Feature/LiveCommissionServiceTest.php index 952d1421..1330ee31 100644 --- a/tests/Feature/LiveCommissionServiceTest.php +++ b/tests/Feature/LiveCommissionServiceTest.php @@ -181,3 +181,81 @@ it('reicht Level- und Quali-Daten im Snapshot durch (N1/N4)', function (): void ->and($snapshot['personal_points'])->toBe(300.0) ->and($snapshot['paylines'])->toBe(3); }); + +it('reicht das Fehlerkennzeichen der Provisionsberechnung durch', function (): void { + // A19: Eine fehlgeschlagene Live-Berechnung liefert Ersatzwerte, aus denen sich + // 0,00 EUR ergibt. Ohne das Kennzeichen ist das in der Oberflaeche nicht von + // einem echten Nullmonat zu unterscheiden. + $user = lcsMakeUser(); + + $service = Mockery::mock(LiveCommissionService::class)->makePartial(); + $service->shouldReceive('snapshot')->once()->andReturn([ + 'payline_points' => 0.0, + 'paylines' => 0, + 'pp' => 0.0, + 'shop' => 0.0, + 'growth' => 0.0, + 'is_live' => true, + 'failed' => true, + 'qual_level' => null, + 'next_level' => null, + 'payline_points_qual_kp' => 0.0, + 'personal_points' => 0.0, + ]); + + $commission = $service->commission($user, 7, 2026); + + expect($commission['failed'])->toBeTrue() + ->and($commission['total'])->toBe(0.0); +}); + +it('meldet eine echte Nullprovision ohne Fehlerkennzeichen', function (): void { + $user = lcsMakeUser(); + + UserBusiness::forceCreate([ + 'user_id' => $user->id, + 'month' => 6, + 'year' => 2026, + 'version' => 2, + 'commission_pp_total' => 0, + 'commission_shop_sales' => 0, + 'commission_growth_total' => 0, + ]); + + $commission = app(LiveCommissionService::class)->commission($user, 6, 2026); + + expect($commission['total'])->toBe(0.0) + ->and($commission['failed'])->toBeFalse(); +}); + +it('zeigt eine nicht verfuegbare Provisionsberechnung statt 0,00 Euro', function (): void { + URL::resolveMissingNamedRoutesUsing(fn (string $name, $parameters = []) => '/'.$name); + + $html = view('dashboard._statistics', [ + 'monthlyStats' => [ + 'month' => 7, + 'year' => 2026, + 'customer_points' => 0.0, + 'team_payline_points' => 0.0, + 'payline_is_live' => true, + 'payline_forecast' => null, + 'commission' => ['total' => 0.0, 'pp' => 0.0, 'shop' => 0.0, 'growth' => 0.0, 'is_live' => true, 'failed' => true], + 'direct_new_partners' => 0, + 'team_total_abos' => 0, + 'team_partner_abos' => 0, + 'team_partner_abos_new' => 0, + 'team_customer_abos' => 0, + 'team_customer_abos_new' => 0, + 'shop_1000' => 0, + 'new_partners' => 0, + 'own_abos' => 0, + 'own_abos_new' => 0, + 'own_customer_abos' => 0, + 'own_customer_abos_new' => 0, + ], + ])->render(); + + expect($html) + ->toContain(__('home.commission_unavailable')) + ->not->toContain('0,00 €'); +}); diff --git a/tests/Feature/PaylineForecastServiceTest.php b/tests/Feature/PaylineForecastServiceTest.php index fe9efced..31745987 100644 --- a/tests/Feature/PaylineForecastServiceTest.php +++ b/tests/Feature/PaylineForecastServiceTest.php @@ -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(); +}); diff --git a/tests/Feature/TeamAboOverviewTest.php b/tests/Feature/TeamAboOverviewTest.php index 428ce361..3366d602 100644 --- a/tests/Feature/TeamAboOverviewTest.php +++ b/tests/Feature/TeamAboOverviewTest.php @@ -252,6 +252,52 @@ it('liefert Abo-Bestaende monatsbezogen: vergangene Monate zeigen den Bestand zu 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'); @@ -431,3 +477,27 @@ it('liefert null, wenn das Mitglied nicht im Team liegt', function (): void { 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(); +}); diff --git a/tests/Feature/TopResultsServiceTest.php b/tests/Feature/TopResultsServiceTest.php index 2d2bbdbd..d634e84f 100644 --- a/tests/Feature/TopResultsServiceTest.php +++ b/tests/Feature/TopResultsServiceTest.php @@ -125,6 +125,66 @@ it('begrenzt auf Top 3 und sortiert Gleichstaende nach Name', function (): void ->toBe([$carla->id, $anna->id, $zoe->id]); }); +it('beruecksichtigt Gleichstaende jenseits des Auswahlfensters (A11)', function (): void { + // Der Namens-Tiebreak darf nicht an der Groesse des Kandidatenfensters + // haengen: "Alpha" wird zuletzt angelegt und liegt mit 25 anderen gleichauf. + $root = trsMakeUser(null, 'Root', 'Viewer'); + + for ($i = 0; $i < 25; $i++) { + $zulu = trsMakeUser($root->id, 'Zulu'.str_pad((string) $i, 2, '0', STR_PAD_LEFT), 'Zett'); + trsAddVolume($zulu->id, 100, 1); + } + + $alpha = trsMakeUser($root->id, 'Alpha', 'Anfang'); + trsAddVolume($alpha->id, 100, 1); + + $result = trsTop3($root); + + expect($result['top_volume'])->toHaveCount(3) + ->and($result['top_volume'][0]['user_id'])->toBe($alpha->id) + ->and($result['top_volume'][0]['first_name'])->toBe('Alpha'); +}); + +it('fuellt die Top 3 auch auf, wenn viele Kandidaten keinen Account haben (A11)', function (): void { + $root = trsMakeUser(null, 'Root', 'Viewer'); + + // Kandidaten ohne Account koennen nicht angezeigt werden; sie duerfen die + // Auswahl aber auch nicht verstopfen. + for ($i = 0; $i < 25; $i++) { + $orphan = User::forceCreate([ + 'email' => 'trs-orphan-'.uniqid('', true).'@example.com', + 'password' => bcrypt('secret'), + 'lang' => 'de', + 'm_level' => 1, + 'm_sponsor' => $root->id, + 'payment_account' => now()->addYear(), + ]); + trsAddVolume($orphan->id, 500, 1); + } + + $berta = trsMakeUser($root->id, 'Berta', 'Bach'); + trsAddVolume($berta->id, 100, 1); + + $result = trsTop3($root); + + expect(array_column($result['top_volume'], 'user_id'))->toBe([$berta->id]); +}); + +it('sortiert Namensgleichheit reproduzierbar nach user_id (A11)', function (): void { + $root = trsMakeUser(null, 'Root', 'Viewer'); + + $first = trsMakeUser($root->id, 'Maria', 'Muster'); + $second = trsMakeUser($root->id, 'Maria', 'Muster'); + $third = trsMakeUser($root->id, 'Maria', 'Muster'); + + foreach ([$first, $second, $third] as $user) { + trsAddVolume($user->id, 100, 1); + } + + expect(array_column(trsTop3($root)['top_volume'], 'user_id')) + ->toBe([$first->id, $second->id, $third->id]); +}); + it('zaehlt Neupartner je Sponsor nach der Dashboard-Definition', function (): void { $root = trsMakeUser(null, 'Root', 'Viewer'); $sponsor = trsMakeUser($root->id, 'Sandra', 'Sponsor'); @@ -240,3 +300,25 @@ it('rendert die Top-Ergebnisse-Karte nur mit Eintraegen', function (): void { expect($emptyHtml)->not->toContain(e(__('home.top_results_title'))); }); + +it('haelt "Top 3 neue Abos" fuer abgeschlossene Monate stabil, auch wenn Abos spaeter enden', function (): void { + // Regression: die Liste zaehlte ueber activeAbo() und schrumpfte damit + // rueckwirkend, sobald ein Abo endete — anders als die Top-3-Neupartner + // daneben, die ueber newPartnerIn() bereits stichtagsstabil sind. + $root = trsMakeUser(null, 'Root', 'Viewer'); + $anna = trsMakeUser($root->id, 'Anna', 'Alpha'); + + // Im Juli gestartet, erst im August gekuendigt: gehoert in die Juli-Zahl. + trsMakeAbo($anna->id, 'ot', '2026-07-10', active: false) + ->update(['cancel_date' => '2026-08-05']); + trsMakeAbo($anna->id, 'ot', '2026-07-20'); + // Im August gestartet: gehoert nicht in die Juli-Zahl. + trsMakeAbo($anna->id, 'ot', '2026-08-02'); + + Cache::flush(); + $result = app(TopResultsService::class)->monthlyTop3($root, 7, 2026); + + expect($result['top_new_abos'])->toHaveCount(1) + ->and($result['top_new_abos'][0]['first_name'])->toBe('Anna') + ->and($result['top_new_abos'][0]['value'])->toBe(2); +});