From 7bc551ca1f2811f6d3bf126d9903d7cb553c3847 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Fri, 9 Oct 2026 13:13:22 +0000 Subject: [PATCH] Anerkennungen: firmenweite Monats-Ranglisten im Salescenter - Neue Seite Anerkennungen (/user/recognitions, Menue unter News Archiv) fuer alle Berater mit aktivem Konto: oben die Anerkennungen des Vormonats in den fuenf Kategorien der Monatsmail, Filter zurueck bis Januar 2026; darunter Live-Ranglisten (Top 10 je Kategorie) des laufenden Monats, 15 Min. Cache. - Kategorien: Top-Recruiter (ab 2 Neupartnern), neue Kunden-Abos, Kundenabo-Punkte, Kunden-Abos gesamt, Gesamtpunkte (1.000-Punkte-Shop, mit Platz 4). Regeln wie TopResultsService/VIP-Statistik, nur firmenweit. - Firmenkonto (6) und Grosskundin (3) nie aufgefuehrt: config mivita.recognition_excluded_user_ids. - Abgeschlossene Monate sind bis zum 3. des Folgemonats vorlaeufig und werden dann eingefroren: neue Tabelle recognition_snapshots, Command recognitions:freeze (taeglich 06:15, idempotent, holt offene Monate nach). - September-Anerkennungen firmenweit geprueft und korrigiert. Co-Authored-By: Claude Opus 5.5 --- app/Console/Commands/RecognitionsFreeze.php | 72 ++++ app/Console/Kernel.php | 4 + .../User/RecognitionController.php | 53 +++ .../Recognition/RecognitionFilterRequest.php | 33 ++ app/Models/RecognitionSnapshot.php | 32 ++ app/Services/Team/RecognitionService.php | 402 ++++++++++++++++++ config/mivita.php | 7 + ...949_create_recognition_snapshots_table.php | 33 ++ .../september-anerkennungen.md | 46 ++ resources/lang/de/navigation.php | 1 + resources/lang/de/recognitions.php | 52 +++ resources/lang/en/navigation.php | 1 + resources/lang/en/recognitions.php | 52 +++ resources/lang/es/navigation.php | 1 + resources/lang/es/recognitions.php | 52 +++ resources/lang/fr/navigation.php | 1 + resources/lang/fr/recognitions.php | 52 +++ .../layouts/includes/layout-sidenav.blade.php | 6 + .../views/user/recognitions/index.blade.php | 324 ++++++++++++++ routes/domains/crm.php | 3 + tests/Feature/RecognitionPageTest.php | 115 +++++ tests/Feature/RecognitionServiceTest.php | 253 +++++++++++ 22 files changed, 1595 insertions(+) create mode 100644 app/Console/Commands/RecognitionsFreeze.php create mode 100644 app/Http/Controllers/User/RecognitionController.php create mode 100644 app/Http/Requests/Recognition/RecognitionFilterRequest.php create mode 100644 app/Models/RecognitionSnapshot.php create mode 100644 app/Services/Team/RecognitionService.php create mode 100644 database/migrations/2026_10_09_131949_create_recognition_snapshots_table.php create mode 100644 dev/2026-08-12-intensiv-tickets/september-anerkennungen.md create mode 100644 resources/lang/de/recognitions.php create mode 100644 resources/lang/en/recognitions.php create mode 100644 resources/lang/es/recognitions.php create mode 100644 resources/lang/fr/recognitions.php create mode 100644 resources/views/user/recognitions/index.blade.php create mode 100644 tests/Feature/RecognitionPageTest.php create mode 100644 tests/Feature/RecognitionServiceTest.php diff --git a/app/Console/Commands/RecognitionsFreeze.php b/app/Console/Commands/RecognitionsFreeze.php new file mode 100644 index 00000000..aa2e114c --- /dev/null +++ b/app/Console/Commands/RecognitionsFreeze.php @@ -0,0 +1,72 @@ +monthsToFreeze(); + $force = (bool) $this->option('force'); + $stored = 0; + $skipped = 0; + + foreach ($months as ['month' => $month, 'year' => $year]) { + if (! RecognitionService::isFreezable($month, $year)) { + $this->line(sprintf('%02d/%d: noch nicht festschreibbar (ab %s)', $month, $year, RecognitionService::freezeDate($month, $year)->format('d.m.Y'))); + $skipped++; + + continue; + } + + if ($recognitions->freeze($month, $year, $force)) { + $this->info(sprintf('%02d/%d: eingefroren', $month, $year)); + $stored++; + } else { + $skipped++; + } + } + + $this->info("Fertig. Eingefroren: {$stored}, uebersprungen: {$skipped}"); + + return self::SUCCESS; + } + + /** + * @return array + */ + private function monthsToFreeze(): array + { + if ($this->option('month') && $this->option('year')) { + return [['month' => max(1, min(12, (int) $this->option('month'))), 'year' => (int) $this->option('year')]]; + } + + return RecognitionService::closedMonths(); + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index fadcd896..ab7c9804 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -98,6 +98,10 @@ class Kernel extends ConsoleKernel // Incentive: Punkteberechnung täglich nach business:store-optimized $schedule->command('incentive:calculate')->dailyAt('05:00'); + // Anerkennungen: abgeschlossene Monate ab dem 3. des Folgemonats einfrieren + // (idempotent; nach Provisionslauf, Repairs und Incentive) + $schedule->command('recognitions:freeze')->dailyAt('06:15'); + // Cleanup old log files weekly (keeps logs for 30 days) $schedule->command('logs:cleanup --days=30')->weekly()->sundays()->at('05:00'); diff --git a/app/Http/Controllers/User/RecognitionController.php b/app/Http/Controllers/User/RecognitionController.php new file mode 100644 index 00000000..8f51cd2f --- /dev/null +++ b/app/Http/Controllers/User/RecognitionController.php @@ -0,0 +1,53 @@ +middleware('active.account'); + } + + public function index(RecognitionFilterRequest $request): View + { + $closedMonths = RecognitionService::closedMonths(); + $selected = $this->selectedMonth((string) $request->validated('period'), $closedMonths); + + return view('user.recognitions.index', [ + 'periods' => $closedMonths, + 'selected' => $selected, + 'recognitions' => $selected + ? $this->recognitions->recognitions($selected['month'], $selected['year']) + : null, + 'live' => $this->recognitions->liveRankings((int) now()->month, (int) now()->year), + ]); + } + + /** + * Nur abgeschlossene Monate sind wählbar; alles andere fällt auf den Vormonat zurück. + * + * @param array $closedMonths + * @return array{month: int, year: int}|null + */ + private function selectedMonth(string $period, array $closedMonths): ?array + { + foreach ($closedMonths as $closedMonth) { + if ($period === sprintf('%d-%02d', $closedMonth['year'], $closedMonth['month'])) { + return $closedMonth; + } + } + + return $closedMonths[0] ?? null; + } +} diff --git a/app/Http/Requests/Recognition/RecognitionFilterRequest.php b/app/Http/Requests/Recognition/RecognitionFilterRequest.php new file mode 100644 index 00000000..fa0fbb20 --- /dev/null +++ b/app/Http/Requests/Recognition/RecognitionFilterRequest.php @@ -0,0 +1,33 @@ +> + */ + public function rules(): array + { + return [ + 'period' => ['nullable', 'date_format:Y-m'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'period.date_format' => __('recognitions.period_invalid'), + ]; + } +} diff --git a/app/Models/RecognitionSnapshot.php b/app/Models/RecognitionSnapshot.php new file mode 100644 index 00000000..03c45ba7 --- /dev/null +++ b/app/Models/RecognitionSnapshot.php @@ -0,0 +1,32 @@ + 'int', + 'month' => 'int', + 'payload' => 'array', + 'frozen_at' => 'datetime', + ]; + } +} diff --git a/app/Services/Team/RecognitionService.php b/app/Services/Team/RecognitionService.php new file mode 100644 index 00000000..99cb6261 --- /dev/null +++ b/app/Services/Team/RecognitionService.php @@ -0,0 +1,402 @@ + 4, + ]; + + /** + * Mindestwert für einen Platz: bei den Recruitern teilen sich sonst oft + * zehn und mehr Partner mit je einem Neupartner einen Platz. + */ + private const MIN_VALUES = [ + 'recruiters' => 2, + ]; + + private const CACHE_MINUTES = 15; + + public function __construct(private TeamAboOverviewService $teamAboOverview) {} + + /** + * Anerkennungen eines abgeschlossenen Monats: eingefroren, falls vorhanden, + * sonst vorläufig berechnet. + * + * @return array{month: int, year: int, is_final: bool, frozen_at: Carbon|null, freeze_date: Carbon, categories: array>}>>} + */ + public function recognitions(int $month, int $year): array + { + $snapshot = RecognitionSnapshot::query() + ->where('year', $year) + ->where('month', $month) + ->first(); + + $categories = $snapshot + ? $snapshot->payload['categories'] + : Cache::remember( + self::cacheKey('podium', $month, $year), + now()->addMinutes(self::CACHE_MINUTES), + fn () => $this->calculate($month, $year)['categories'] + ); + + return [ + 'month' => $month, + 'year' => $year, + 'is_final' => $snapshot !== null, + 'frozen_at' => $snapshot?->frozen_at, + 'freeze_date' => self::freezeDate($month, $year), + 'categories' => $this->withProfileImages($categories, 'members'), + ]; + } + + /** + * Live-Ranglisten (Top {@see self::LIVE_LIMIT}) je Kategorie, 15 Minuten gecacht. + * + * @return array{month: int, year: int, calculated_at: Carbon, categories: array>>} + */ + public function liveRankings(int $month, int $year): array + { + $cached = Cache::remember( + self::cacheKey('live', $month, $year), + now()->addMinutes(self::CACHE_MINUTES), + fn () => [ + 'calculated_at' => now()->toIso8601String(), + 'categories' => $this->calculate($month, $year)['rankings'], + ] + ); + + return [ + 'month' => $month, + 'year' => $year, + 'calculated_at' => Carbon::parse($cached['calculated_at']), + 'categories' => $this->withProfileImages($cached['categories']), + ]; + } + + /** + * Friert einen abgeschlossenen Monat ein. Ein vorhandener Snapshot bleibt + * ohne $force unangetastet. + */ + public function freeze(int $month, int $year, bool $force = false): bool + { + $exists = RecognitionSnapshot::query()->where('year', $year)->where('month', $month)->exists(); + + if ($exists && ! $force) { + return false; + } + + RecognitionSnapshot::query()->updateOrCreate( + ['year' => $year, 'month' => $month], + ['payload' => $this->calculate($month, $year), 'frozen_at' => now()] + ); + + Cache::forget(self::cacheKey('podium', $month, $year)); + + return true; + } + + /** + * Ab diesem Zeitpunkt gilt ein Monat als festgeschrieben. + */ + public static function freezeDate(int $month, int $year): Carbon + { + return Carbon::create($year, $month, 1)->addMonthNoOverflow()->day(self::FREEZE_DAY)->startOfDay(); + } + + public static function isFreezable(int $month, int $year): bool + { + return now()->gte(self::freezeDate($month, $year)); + } + + /** + * Abgeschlossene Monate von heute rückwärts bis zum ersten Anerkennungsmonat. + * + * @return array + */ + public static function closedMonths(): array + { + $months = []; + $cursor = now()->startOfMonth()->subMonthNoOverflow(); + $first = Carbon::create(self::FIRST_YEAR, self::FIRST_MONTH, 1); + + while ($cursor->gte($first)) { + $months[] = ['month' => (int) $cursor->month, 'year' => (int) $cursor->year]; + $cursor->subMonthNoOverflow(); + } + + return $months; + } + + /** + * @return array{categories: array>>, rankings: array>>} + */ + private function calculate(int $month, int $year): array + { + $userIds = $this->eligibleUserIds(); + $result = ['categories' => [], 'rankings' => []]; + + foreach (self::CATEGORIES as $category) { + $entries = $this->sortedEntries($this->categoryValues($category, $userIds, $month, $year)); + + $result['categories'][$category] = self::podium( + $entries, + self::PODIUM_PLACES[$category] ?? self::DEFAULT_PODIUM_PLACES, + self::MIN_VALUES[$category] ?? 0 + ); + $result['rankings'][$category] = self::ranking($entries, self::LIVE_LIMIT); + } + + return $result; + } + + /** + * Alle Berater außer den nie aufgeführten Konten (config mivita.recognition_excluded_user_ids). + * + * @return int[] + */ + private function eligibleUserIds(): array + { + return User::query() + ->whereNotNull('m_level') + ->whereNull('deleted_at') + ->whereNotIn('id', config('mivita.recognition_excluded_user_ids', [])) + ->whereHas('account') + ->pluck('id') + ->map(fn ($id) => (int) $id) + ->all(); + } + + /** + * @param int[] $userIds + * @return array user_id => Wert (> 0) + */ + private function categoryValues(string $category, array $userIds, int $month, int $year): array + { + if ($userIds === []) { + return []; + } + + $values = match ($category) { + 'recruiters' => User::query() + ->firstlinesOf($userIds) + ->newPartnerIn($month, $year) + ->groupBy('m_sponsor') + ->selectRaw('m_sponsor, COUNT(*) as value') + ->pluck('value', 'm_sponsor') + ->map(fn ($value) => (int) $value), + 'new_customer_abos' => $this->teamAboOverview + ->aboStockQuery($userIds, 'member_id', 'ot', $month, $year) + ->whereBetween('start_date', [ + Carbon::create($year, $month, 1)->startOfMonth(), + Carbon::create($year, $month, 1)->endOfMonth(), + ]) + ->groupBy('member_id') + ->selectRaw('member_id, COUNT(*) as value') + ->pluck('value', 'member_id') + ->map(fn ($value) => (int) $value), + 'customer_abos_total' => $this->teamAboOverview + ->aboStockQuery($userIds, 'member_id', 'ot', $month, $year) + ->groupBy('member_id') + ->selectRaw('member_id, COUNT(*) as value') + ->pluck('value', 'member_id') + ->map(fn ($value) => (int) $value), + 'customer_abo_points' => UserSalesVolume::query() + ->leftJoin('shopping_orders', 'shopping_orders.id', '=', 'user_sales_volumes.shopping_order_id') + ->whereIn('user_sales_volumes.user_id', $userIds) + ->where('user_sales_volumes.month', $month) + ->where('user_sales_volumes.year', $year) + ->groupBy('user_sales_volumes.user_id') + ->selectRaw('user_sales_volumes.user_id, '.SalesVolumePoints::customerAboPoints().' as value') + ->pluck('value', 'user_id') + ->map(fn ($value) => round((float) $value, 2)), + 'total_points' => SalesVolumePoints::totalPointsByUser($userIds, $month, $year) + ->map(fn (float $points) => round($points, 2)), + }; + + return $values->filter(fn ($value) => $value > 0)->all(); + } + + /** + * Wert absteigend, bei Gleichstand nach Name und zuletzt nach user_id, damit + * die Reihenfolge reproduzierbar bleibt. + * + * @param array $valuesByUserId + * @return array + */ + private function sortedEntries(array $valuesByUserId): array + { + if ($valuesByUserId === []) { + return []; + } + + $users = User::query() + ->with('account') + ->whereIn('id', array_keys($valuesByUserId)) + ->get() + ->keyBy('id'); + + $entries = []; + foreach ($valuesByUserId as $userId => $value) { + $account = $users->get($userId)?->account; + if (! $account) { + continue; + } + + $entries[] = [ + 'user_id' => (int) $userId, + 'first_name' => trim((string) $account->first_name), + 'last_name' => trim((string) $account->last_name), + 'value' => $value, + ]; + } + + usort($entries, 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 $entries; + } + + /** + * Plätze wie in der Mail: Gleichstand teilt sich einen Platz, der nächste + * Wert bekommt den nächsten Platz (1, 2, 2, 3 → Platz 1, 2, 3). + * + * @param array $entries + * @return array}> + */ + private static function podium(array $entries, int $places, float|int $minValue): array + { + $podium = []; + + foreach ($entries as $entry) { + if ($entry['value'] < $minValue) { + break; + } + + $current = array_key_last($podium); + if ($current === null || $podium[$current]['value'] != $entry['value']) { + if (count($podium) >= $places) { + break; + } + + $podium[] = ['place' => count($podium) + 1, 'value' => $entry['value'], 'members' => []]; + $current = array_key_last($podium); + } + + $podium[$current]['members'][] = [ + 'user_id' => $entry['user_id'], + 'first_name' => $entry['first_name'], + 'last_name' => $entry['last_name'], + ]; + } + + return $podium; + } + + /** + * Rangliste mit Wettkampf-Rang (1, 2, 2, 4), auf $limit Zeilen gekürzt. + * + * @param array $entries + * @return array + */ + private static function ranking(array $entries, int $limit): array + { + $rows = []; + + foreach (array_slice($entries, 0, $limit) as $index => $entry) { + $previous = $rows[$index - 1] ?? null; + $rank = $previous !== null && $previous['value'] == $entry['value'] ? $previous['rank'] : $index + 1; + + $rows[] = ['rank' => $rank] + $entry; + } + + return $rows; + } + + /** + * Profilfotos erst beim Lesen ergänzen — nicht mit einfrieren oder cachen, + * der Dateiname ändert sich bei jedem Upload ({@see TopResultsService}). + * + * @param array>> $categories + * @return array>> + */ + private function withProfileImages(array $categories, ?string $nestedKey = null): array + { + $userIds = collect($categories)->flatten(1) + ->flatMap(fn (array $row) => $nestedKey ? $row[$nestedKey] : [$row]) + ->pluck('user_id') + ->unique() + ->values() + ->all(); + + if ($userIds === []) { + return $categories; + } + + $imageUrls = User::query() + ->with('account') + ->whereIn('id', $userIds) + ->get() + ->mapWithKeys(fn (User $member) => [(int) $member->id => $member->account?->getProfileImageUrl()]); + + $addImage = fn (array $row) => $row + ['profile_image_url' => $imageUrls->get((int) $row['user_id'])]; + + foreach ($categories as $category => $rows) { + foreach ($rows as $index => $row) { + if ($nestedKey) { + $categories[$category][$index][$nestedKey] = array_map($addImage, $row[$nestedKey]); + } else { + $categories[$category][$index] = $addImage($row); + } + } + } + + return $categories; + } + + private static function cacheKey(string $type, int $month, int $year): string + { + return "recognitions_{$type}_{$year}_{$month}"; + } +} diff --git a/config/mivita.php b/config/mivita.php index 2cea184f..07b2c5cf 100755 --- a/config/mivita.php +++ b/config/mivita.php @@ -19,4 +19,11 @@ return [ // Berater landen, sind User-Fehler über die Hauptwebseite — VP-Punkte // stehen immer dem Berater zu (Kundenentscheid 2026-08-19). 'company_user_id' => (int) env('MIVITA_COMPANY_USER_ID', 6), + + // Anerkennungen (Monats-Ranglisten im Salescenter): diese Konten werden nie + // aufgeführt — Firmenkonto (6) und Großkundin Ruth Krummel (3), Kundenentscheid 2026-10-07. + 'recognition_excluded_user_ids' => array_values(array_filter(array_map( + 'intval', + explode(',', (string) env('MIVITA_RECOGNITION_EXCLUDED_USER_IDS', '3,6')) + ))), ]; diff --git a/database/migrations/2026_10_09_131949_create_recognition_snapshots_table.php b/database/migrations/2026_10_09_131949_create_recognition_snapshots_table.php new file mode 100644 index 00000000..83309bed --- /dev/null +++ b/database/migrations/2026_10_09_131949_create_recognition_snapshots_table.php @@ -0,0 +1,33 @@ +id(); + $table->unsignedSmallInteger('year'); + $table->unsignedTinyInteger('month'); + $table->json('payload'); + $table->timestamp('frozen_at')->nullable(); + $table->timestamps(); + + $table->unique(['year', 'month'], 'recognition_snapshot_unique'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('recognition_snapshots'); + } +}; diff --git a/dev/2026-08-12-intensiv-tickets/september-anerkennungen.md b/dev/2026-08-12-intensiv-tickets/september-anerkennungen.md new file mode 100644 index 00000000..c61ba924 --- /dev/null +++ b/dev/2026-08-12-intensiv-tickets/september-anerkennungen.md @@ -0,0 +1,46 @@ +Herzlichen Glückwunsch zu euren großartigen Erfolgen! 🥳💙 + + +🏆 Top-Recruiter – September +🥇 Gabi Ramstötter (2) +🥇 Tanja Longerich (2) +🥇 Claudia Lattner (2) + +🌟 Die meisten neuen Kunden-Abos im September +🥇 Platz 1: Verena Zöllinger (7) +🥈 Platz 2: Nicola Maier (6) +🥉 Platz 3: Rosa Maria Assenbaum (4), Carmen Graf (4) + +💙 Höchster Umsatz mit Kunden-Abos +🥇 Platz 1: Cornelia Böhm-Sodan – 1.096 Punkte +🥈 Platz 2: Annette Harsch – 1.042 Punkte +🥉 Platz 3: Tanja Longerich – 930 Punkte + +⭐ Die meisten Kunden-Abos insgesamt +🥇 Platz 1: Annette Harsch (21) +🥈 Platz 2: Rosa Maria Assenbaum (19) +🥉 Platz 3: Nina Fleschhut (16), Tanja Longerich (16) + +🚀 Top 3 durch Kundenumsatz & Recruiting – 1.000-Punkte-Shop +🥇 Platz 1: Claudia Lattner (5.808 P) +🥈 Platz 2: Daniel Biallowons (4.202 P) +🥉 Platz 3: Tanja Longerich (3.279 P) +Platz 4: Claudia Zürn (3.001 P) + + +--- + +## Prüfung firmenweit (07.10.2026, Live-Stand) – nicht mitschicken + +Nachgerechnet über die gesamte Firma statt nur Claudias Team, mit denselben Regeln wie +Dashboard „Die besten Ergebnisse“ und VIP-Statistik. Grundsätzlich ohne das +Firmenkonto (mivita care gmbh, User 6) und ohne Ruth Krummel (User 3, Großkunde) – +beide werden in den Anerkennungen nie aufgeführt (bestätigt von Kevin, 07.10.2026). + +| Kategorie | Ergebnis | +|---|---| +| Top-Recruiter | ✅ stimmt (je 2; dahinter rund 10 Partner mit je 1) | +| Neue Kunden-Abos | ✅ stimmt; Name korrigiert: Rosa Maria Assenbaum | +| Umsatz Kunden-Abos | ✅ stimmt (Böhm-Sodan genau 1.096,5 P) | +| Kunden-Abos insgesamt | ✅ stimmt; Name korrigiert: Rosa Maria Assenbaum (Klammer fehlte) | +| 1.000-Punkte-Shop | ❌ **falsch**: Daniel Biallowons (4.202,5 P) und Claudia Zürn (3.001,5 P) sind nicht in Claudias Team und fehlten. Jasmin Neuhauser (2.637 P) rutscht auf Platz 5 | diff --git a/resources/lang/de/navigation.php b/resources/lang/de/navigation.php index ef50d4e6..b8bceecd 100644 --- a/resources/lang/de/navigation.php +++ b/resources/lang/de/navigation.php @@ -87,6 +87,7 @@ return [ 'tools' => 'Tools', 'system_status' => 'Systemstatus', 'news_archive' => 'News Archiv', + 'recognitions' => 'Anerkennungen', 'incentive' => 'Incentive', 'incentives' => 'Incentives', 'create' => 'Neu anlegen', diff --git a/resources/lang/de/recognitions.php b/resources/lang/de/recognitions.php new file mode 100644 index 00000000..997a8c7b --- /dev/null +++ b/resources/lang/de/recognitions.php @@ -0,0 +1,52 @@ + 'Anerkennungen', + 'hero_kicker' => 'Anerkennungen des Monats', + 'hero_title' => 'Unsere Erfolge im :month', + 'hero_text' => 'Herzlichen Glückwunsch zu euren großartigen Erfolgen! Hinter jedem Platz stehen Begeisterung, Ausdauer und ganz viel Einsatz – wir sind sehr stolz auf euch.', + 'period_label' => 'Monat', + 'period_invalid' => 'Bitte einen gültigen Monat wählen.', + 'show' => 'Anzeigen', + 'status_final' => 'Endgültig · festgeschrieben am :date', + 'status_preliminary' => 'Vorläufig · wird am :date festgeschrieben', + 'place' => 'Platz :place', + 'empty' => 'In diesem Monat gibt es hier keine Platzierungen.', + 'no_months' => 'Es gibt noch keinen abgeschlossenen Monat.', + 'recruiters_min_hint' => 'Platzierung ab 2 Neupartnern im Monat.', + 'live_title' => 'Live: So entwickelt sich der :month', + 'live_text' => 'Die aktuellen Top :limit je Kategorie – alles ist noch offen!', + 'live_updated' => 'Stand: :time Uhr · wird alle 15 Minuten aktualisiert', + 'live_rank' => 'Platz', + 'live_name' => 'Name', + 'live_value' => 'Wert', + 'live_empty' => 'Noch keine Einträge in diesem Monat.', + 'self' => 'ich', + 'categories' => [ + 'recruiters' => [ + 'title' => 'Top-Recruiter', + 'description' => 'Die meisten neuen Partner im Monat (direkt gesponsert, Starterpaket bezahlt).', + 'unit' => 'Neupartner', + ], + 'new_customer_abos' => [ + 'title' => 'Die meisten neuen Kunden-Abos', + 'description' => 'Neu abgeschlossene Kunden-Abos im Monat.', + 'unit' => 'neue Abos', + ], + 'customer_abo_points' => [ + 'title' => 'Höchster Umsatz mit Kunden-Abos', + 'description' => 'Punkte aus Kunden-Abo-Bestellungen im Monat.', + 'unit' => 'Punkte', + ], + 'customer_abos_total' => [ + 'title' => 'Die meisten Kunden-Abos insgesamt', + 'description' => 'Aktive Kunden-Abos zum Monatsende (im laufenden Monat: heute).', + 'unit' => 'Abos', + ], + 'total_points' => [ + 'title' => 'Kundenumsatz & Recruiting – 1.000-Punkte-Shop', + 'description' => 'Höchste Punktzahl aus Kundenumsatz, Eigenumsatz und Recruiting.', + 'unit' => 'Punkte', + ], + ], +]; diff --git a/resources/lang/en/navigation.php b/resources/lang/en/navigation.php index 9c2221d0..357e0814 100644 --- a/resources/lang/en/navigation.php +++ b/resources/lang/en/navigation.php @@ -87,6 +87,7 @@ return [ 'tools' => 'Tools', 'system_status' => 'System status', 'news_archive' => 'News Archive', + 'recognitions' => 'Recognitions', 'incentive' => 'Incentive', 'incentives' => 'Incentives', 'create' => 'Create new', diff --git a/resources/lang/en/recognitions.php b/resources/lang/en/recognitions.php new file mode 100644 index 00000000..93557052 --- /dev/null +++ b/resources/lang/en/recognitions.php @@ -0,0 +1,52 @@ + 'Recognitions', + 'hero_kicker' => 'Recognitions of the month', + 'hero_title' => 'Our successes in :month', + 'hero_text' => 'Congratulations on your great achievements! Every place stands for enthusiasm, perseverance and a lot of commitment – we are very proud of you.', + 'period_label' => 'Month', + 'period_invalid' => 'Please choose a valid month.', + 'show' => 'Show', + 'status_final' => 'Final · locked on :date', + 'status_preliminary' => 'Preliminary · will be locked on :date', + 'place' => 'Place :place', + 'empty' => 'No placements this month.', + 'no_months' => 'There is no completed month yet.', + 'recruiters_min_hint' => 'Placement from 2 new partners per month.', + 'live_title' => 'Live: how :month is shaping up', + 'live_text' => 'The current top :limit per category – everything is still open!', + 'live_updated' => 'As of :time · updated every 15 minutes', + 'live_rank' => 'Place', + 'live_name' => 'Name', + 'live_value' => 'Value', + 'live_empty' => 'No entries this month yet.', + 'self' => 'me', + 'categories' => [ + 'recruiters' => [ + 'title' => 'Top recruiters', + 'description' => 'Most new partners in the month (directly sponsored, starter package paid).', + 'unit' => 'new partners', + ], + 'new_customer_abos' => [ + 'title' => 'Most new customer subscriptions', + 'description' => 'Newly started customer subscriptions in the month.', + 'unit' => 'new subscriptions', + ], + 'customer_abo_points' => [ + 'title' => 'Highest revenue from customer subscriptions', + 'description' => 'Points from customer subscription orders in the month.', + 'unit' => 'points', + ], + 'customer_abos_total' => [ + 'title' => 'Most customer subscriptions overall', + 'description' => 'Active customer subscriptions at month end (current month: today).', + 'unit' => 'subscriptions', + ], + 'total_points' => [ + 'title' => 'Customer revenue & recruiting – 1,000 points shop', + 'description' => 'Highest points from customer revenue, own revenue and recruiting.', + 'unit' => 'points', + ], + ], +]; diff --git a/resources/lang/es/navigation.php b/resources/lang/es/navigation.php index ccc6f19d..72bfa633 100644 --- a/resources/lang/es/navigation.php +++ b/resources/lang/es/navigation.php @@ -84,6 +84,7 @@ return [ 'tools' => 'Herramientas', 'system_status' => 'Estado del sistema', 'news_archive' => 'Archivo de Noticias', + 'recognitions' => 'Reconocimientos', 'incentive' => 'Incentivo', 'incentives' => 'Incentivos', 'create' => 'Crear nuevo', diff --git a/resources/lang/es/recognitions.php b/resources/lang/es/recognitions.php new file mode 100644 index 00000000..9edb8bd5 --- /dev/null +++ b/resources/lang/es/recognitions.php @@ -0,0 +1,52 @@ + 'Reconocimientos', + 'hero_kicker' => 'Reconocimientos del mes', + 'hero_title' => 'Nuestros éxitos en :month', + 'hero_text' => '¡Enhorabuena por vuestros grandes logros! Detrás de cada puesto hay entusiasmo, constancia y mucho esfuerzo: estamos muy orgullosos de vosotros.', + 'period_label' => 'Mes', + 'period_invalid' => 'Elige un mes válido.', + 'show' => 'Mostrar', + 'status_final' => 'Definitivo · cerrado el :date', + 'status_preliminary' => 'Provisional · se cerrará el :date', + 'place' => 'Puesto :place', + 'empty' => 'No hay clasificaciones este mes.', + 'no_months' => 'Todavía no hay ningún mes cerrado.', + 'recruiters_min_hint' => 'Clasificación a partir de 2 nuevos socios en el mes.', + 'live_title' => 'En directo: así evoluciona :month', + 'live_text' => 'El top :limit actual por categoría: ¡todo sigue abierto!', + 'live_updated' => 'Estado: :time · se actualiza cada 15 minutos', + 'live_rank' => 'Puesto', + 'live_name' => 'Nombre', + 'live_value' => 'Valor', + 'live_empty' => 'Todavía no hay entradas este mes.', + 'self' => 'yo', + 'categories' => [ + 'recruiters' => [ + 'title' => 'Mejores reclutadores', + 'description' => 'Más socios nuevos en el mes (patrocinio directo, paquete de inicio pagado).', + 'unit' => 'socios nuevos', + ], + 'new_customer_abos' => [ + 'title' => 'Más suscripciones nuevas de clientes', + 'description' => 'Suscripciones de clientes iniciadas en el mes.', + 'unit' => 'suscripciones nuevas', + ], + 'customer_abo_points' => [ + 'title' => 'Mayor facturación con suscripciones de clientes', + 'description' => 'Puntos de pedidos de suscripciones de clientes en el mes.', + 'unit' => 'puntos', + ], + 'customer_abos_total' => [ + 'title' => 'Más suscripciones de clientes en total', + 'description' => 'Suscripciones de clientes activas a final de mes (mes en curso: hoy).', + 'unit' => 'suscripciones', + ], + 'total_points' => [ + 'title' => 'Facturación de clientes y reclutamiento – tienda de 1.000 puntos', + 'description' => 'Mayor puntuación por facturación de clientes, facturación propia y reclutamiento.', + 'unit' => 'puntos', + ], + ], +]; diff --git a/resources/lang/fr/navigation.php b/resources/lang/fr/navigation.php index 72eafb7c..6add1ef6 100644 --- a/resources/lang/fr/navigation.php +++ b/resources/lang/fr/navigation.php @@ -84,6 +84,7 @@ return [ 'tools' => 'Outils', 'system_status' => 'État du système', 'news_archive' => 'Archive des actualités', + 'recognitions' => 'Distinctions', 'incentive' => 'Incentive', 'incentives' => 'Incentives', 'create' => 'Créer', diff --git a/resources/lang/fr/recognitions.php b/resources/lang/fr/recognitions.php new file mode 100644 index 00000000..69f58c72 --- /dev/null +++ b/resources/lang/fr/recognitions.php @@ -0,0 +1,52 @@ + 'Distinctions', + 'hero_kicker' => 'Distinctions du mois', + 'hero_title' => 'Nos succès en :month', + 'hero_text' => 'Félicitations pour vos magnifiques résultats ! Derrière chaque place, il y a de l’enthousiasme, de la persévérance et beaucoup d’engagement – nous sommes très fiers de vous.', + 'period_label' => 'Mois', + 'period_invalid' => 'Veuillez choisir un mois valide.', + 'show' => 'Afficher', + 'status_final' => 'Définitif · arrêté le :date', + 'status_preliminary' => 'Provisoire · sera arrêté le :date', + 'place' => 'Place :place', + 'empty' => 'Aucun classement ce mois-ci.', + 'no_months' => 'Il n’y a encore aucun mois clôturé.', + 'recruiters_min_hint' => 'Classement à partir de 2 nouveaux partenaires dans le mois.', + 'live_title' => 'En direct : l’évolution de :month', + 'live_text' => 'Le top :limit actuel par catégorie – tout reste possible !', + 'live_updated' => 'État : :time · mis à jour toutes les 15 minutes', + 'live_rank' => 'Place', + 'live_name' => 'Nom', + 'live_value' => 'Valeur', + 'live_empty' => 'Pas encore d’entrées ce mois-ci.', + 'self' => 'moi', + 'categories' => [ + 'recruiters' => [ + 'title' => 'Meilleurs recruteurs', + 'description' => 'Le plus de nouveaux partenaires dans le mois (parrainage direct, pack de démarrage payé).', + 'unit' => 'nouveaux partenaires', + ], + 'new_customer_abos' => [ + 'title' => 'Le plus de nouveaux abonnements clients', + 'description' => 'Abonnements clients démarrés dans le mois.', + 'unit' => 'nouveaux abonnements', + ], + 'customer_abo_points' => [ + 'title' => 'Plus gros chiffre d’affaires avec les abonnements clients', + 'description' => 'Points issus des commandes d’abonnements clients dans le mois.', + 'unit' => 'points', + ], + 'customer_abos_total' => [ + 'title' => 'Le plus d’abonnements clients au total', + 'description' => 'Abonnements clients actifs en fin de mois (mois en cours : aujourd’hui).', + 'unit' => 'abonnements', + ], + 'total_points' => [ + 'title' => 'CA clients & recrutement – boutique 1 000 points', + 'description' => 'Le plus de points grâce au CA clients, au CA personnel et au recrutement.', + 'unit' => 'points', + ], + ], +]; diff --git a/resources/views/layouts/includes/layout-sidenav.blade.php b/resources/views/layouts/includes/layout-sidenav.blade.php index 2a647d96..0711bbf4 100755 --- a/resources/views/layouts/includes/layout-sidenav.blade.php +++ b/resources/views/layouts/includes/layout-sidenav.blade.php @@ -55,6 +55,12 @@
{{ __('navigation.news_archive') }}
+
  • + +
    {{ __('navigation.recognitions') }}
    +
    +
  • @if (Auth::user()->isVIP())
  • diff --git a/resources/views/user/recognitions/index.blade.php b/resources/views/user/recognitions/index.blade.php new file mode 100644 index 00000000..15d4c5da --- /dev/null +++ b/resources/views/user/recognitions/index.blade.php @@ -0,0 +1,324 @@ +@extends('layouts.layout-2') + +@section('styles') + +@endsection + +@section('content') + @php + $viewerId = (int) auth()->id(); + $medals = [1 => '🥇', 2 => '🥈', 3 => '🥉']; + $categoryIcons = [ + 'recruiters' => '🏆', + 'new_customer_abos' => '🌟', + 'customer_abo_points' => '💙', + 'customer_abos_total' => '⭐', + 'total_points' => '🚀', + ]; + $formatValue = fn ($value) => number_format((float) $value, floor((float) $value) == (float) $value ? 0 : 1, ',', '.'); + $periodOptions = collect($periods)->mapWithKeys(fn (array $period) => [ + sprintf('%d-%02d', $period['year'], $period['month']) => HTMLHelper::getMonth($period['month']).' '.$period['year'], + ])->all(); + $selectedPeriod = $selected ? sprintf('%d-%02d', $selected['year'], $selected['month']) : null; + @endphp + +

    +
    + {{ __('recognitions.title') }} +
    + @if ($periodOptions !== []) +
    + + {{ html()->select('period', $periodOptions, $selectedPeriod)->id('period')->class('custom-select custom-select-sm mr-2')->attribute('onchange', 'this.form.submit()') }} + +
    + @endif +

    + + @if ($recognitions === null) +
    {{ __('recognitions.no_months') }}
    + @else +
    + +
    +
    {{ __('recognitions.hero_kicker') }}
    +

    + {{ __('recognitions.hero_title', ['month' => HTMLHelper::getMonth($recognitions['month']).' '.$recognitions['year']]) }} + 🥳💙 +

    +

    {{ __('recognitions.hero_text') }}

    + @if ($recognitions['is_final']) + + {{ __('recognitions.status_final', ['date' => $recognitions['frozen_at']?->format('d.m.Y')]) }} + + @else + + {{ __('recognitions.status_preliminary', ['date' => $recognitions['freeze_date']->format('d.m.Y')]) }} + + @endif +
    +
    + +
    + @foreach (\App\Services\Team\RecognitionService::CATEGORIES as $category) + @php $podium = $recognitions['categories'][$category] ?? []; @endphp +
    +
    +
    + {{ $categoryIcons[$category] }} +
    +
    {{ __('recognitions.categories.'.$category.'.title') }}
    +
    {{ __('recognitions.categories.'.$category.'.description') }}
    +
    +
    +
    + @forelse ($podium as $place) +
    +
    + @if (isset($medals[$place['place']])) + {{ $medals[$place['place']] }} + @else + {{ $place['place'] }} + @endif +
    +
    + @foreach ($place['members'] as $member) +
    + @include('components._avatar', [ + 'imageUrl' => $member['profile_image_url'] ?? null, + 'firstName' => $member['first_name'], + 'lastName' => $member['last_name'], + 'size' => 36, + ]) + + {{ $member['first_name'] }} {{ $member['last_name'] }} + @if ((int) $member['user_id'] === $viewerId) + {{ __('recognitions.self') }} + @endif + +
    + @endforeach +
    +
    +
    {{ $formatValue($place['value']) }}
    +
    {{ __('recognitions.categories.'.$category.'.unit') }}
    +
    +
    + @empty +
    {{ __('recognitions.empty') }}
    + @endforelse + @if ($category === 'recruiters') +
    {{ __('recognitions.recruiters_min_hint') }}
    + @endif +
    +
    +
    + @endforeach +
    + @endif + +
    +
    +
    + + {{ __('recognitions.live_title', ['month' => HTMLHelper::getMonth($live['month']).' '.$live['year']]) }} +
    +
    + {{ __('recognitions.live_text', ['limit' => \App\Services\Team\RecognitionService::LIVE_LIMIT]) }} + · {{ __('recognitions.live_updated', ['time' => $live['calculated_at']->format('d.m.Y H:i')]) }} +
    +
    +
    + +
    + @foreach (\App\Services\Team\RecognitionService::CATEGORIES as $category) +
    + @if (empty($live['categories'][$category])) +
    {{ __('recognitions.live_empty') }}
    + @else +
    + + + + + + + + + + @foreach ($live['categories'][$category] as $row) + + + + + + @endforeach + +
    {{ __('recognitions.live_rank') }}{{ __('recognitions.live_name') }}{{ __('recognitions.categories.'.$category.'.unit') }}
    + {{ $medals[$row['rank']] ?? $row['rank'].'.' }} + +
    + @include('components._avatar', [ + 'imageUrl' => $row['profile_image_url'] ?? null, + 'firstName' => $row['first_name'], + 'lastName' => $row['last_name'], + 'size' => 32, + ]) + + {{ $row['first_name'] }} {{ $row['last_name'] }} + @if ((int) $row['user_id'] === $viewerId) + {{ __('recognitions.self') }} + @endif + +
    +
    {{ $formatValue($row['value']) }}
    +
    + @endif +
    + @endforeach +
    +
    +
    +@endsection diff --git a/routes/domains/crm.php b/routes/domains/crm.php index b98e8881..d19de962 100644 --- a/routes/domains/crm.php +++ b/routes/domains/crm.php @@ -134,6 +134,9 @@ Route::domain(config('app.pre_url_crm').config('app.domain').config('app.tld_car Route::get('/user/backoffice/statistics/export', 'User\BackofficeStatisticsController@export')->name('user_backoffice_statistics_export'); Route::get('/user/backoffice/statistics/export-overview', 'User\BackofficeStatisticsController@overviewExport')->name('user_backoffice_statistics_overview_export'); + // user recognitions (Anerkennungen des Monats, firmenweit) + Route::get('/user/recognitions', 'User\RecognitionController@index')->name('user_recognitions'); + // user team Route::get('/user/team/add/member', 'User\TeamController@addMember')->name('user_team_add_member'); diff --git a/tests/Feature/RecognitionPageTest.php b/tests/Feature/RecognitionPageTest.php new file mode 100644 index 00000000..94772279 --- /dev/null +++ b/tests/Feature/RecognitionPageTest.php @@ -0,0 +1,115 @@ +previousHttpHost = $_SERVER['HTTP_HOST'] ?? null; + $_SERVER['HTTP_HOST'] = config('domains.domains.crm.host'); + $this->refreshApplication(); + $this->artisan('migrate'); + + Carbon::setTestNow(Carbon::parse('2026-10-09 10:00:00')); + Cache::flush(); + config(['mivita.recognition_excluded_user_ids' => []]); +}); + +afterEach(function () { + Carbon::setTestNow(); + + // Host wieder zurücksetzen, sonst booten nachfolgende Tests mit den CRM-Routen. + if ($this->previousHttpHost === null) { + unset($_SERVER['HTTP_HOST']); + } else { + $_SERVER['HTTP_HOST'] = $this->previousHttpHost; + } +}); + +function recPageUser(string $firstName, string $lastName, array $attributes = []): User +{ + $account = UserAccount::forceCreate(['first_name' => $firstName, 'last_name' => $lastName]); + + return User::forceCreate(array_merge([ + 'email' => 'recp-'.uniqid('', true).'@example.com', + 'password' => bcrypt('secret'), + 'lang' => 'de', + 'active' => 1, + 'wizard' => 100, + 'm_level' => 1, + 'payment_account' => now()->addYear(), + 'account_id' => $account->id, + ], $attributes)); +} + +function recPageVolume(int $userId, int $points, int $month): void +{ + UserSalesVolume::forceCreate([ + 'user_id' => $userId, + 'month' => $month, + 'year' => 2026, + 'points' => $points, + 'status' => 1, + ]); +} + +it('zeigt aktiven Beratern die Anerkennungen des Vormonats und die Live-Tabelle', function () { + $viewer = recPageUser('Vera', 'Viewer'); + $september = recPageUser('Sina', 'September'); + $october = recPageUser('Otto', 'Oktober'); + recPageVolume($september->id, 5808, 9); + recPageVolume($october->id, 1234, 10); + recPageVolume($viewer->id, 800, 10); + + $this->actingAs($viewer)->get(route('user_recognitions')) + ->assertOk() + ->assertSee('Unsere Erfolge im September 2026') + ->assertSee('Vorläufig · wird am 03.10.2026 festgeschrieben', false) + ->assertSee('Sina September') + ->assertSee('5.808') + ->assertSee('Live: So entwickelt sich der Oktober 2026') + ->assertSee('Otto Oktober') + ->assertSee('1.234') + ->assertSee('', false) + ->assertSee('href="'.route('user_recognitions').'"', false); +}); + +it('zeigt eingefrorene Monate als endgültig und wechselt per Filter den Monat', function () { + $viewer = recPageUser('Vera', 'Viewer'); + $august = recPageUser('Augusta', 'August'); + recPageVolume($august->id, 4321, 8); + $this->artisan('recognitions:freeze', ['--month' => 8, '--year' => 2026])->assertSuccessful(); + + expect(RecognitionSnapshot::query()->where('month', 8)->exists())->toBeTrue(); + + $this->actingAs($viewer)->get(route('user_recognitions', ['period' => '2026-08'])) + ->assertOk() + ->assertSee('Unsere Erfolge im August 2026') + ->assertSee('Endgültig · festgeschrieben am 09.10.2026', false) + ->assertSee('Augusta August') + ->assertSee('