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 <noreply@anthropic.com>
This commit is contained in:
parent
9ad69a3218
commit
7bc551ca1f
22 changed files with 1595 additions and 0 deletions
72
app/Console/Commands/RecognitionsFreeze.php
Normal file
72
app/Console/Commands/RecognitionsFreeze.php
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Team\RecognitionService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class RecognitionsFreeze extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'recognitions:freeze
|
||||||
|
{--month= : Nur diesen Monat einfrieren (mit --year)}
|
||||||
|
{--year= : Nur dieses Jahr einfrieren (mit --month)}
|
||||||
|
{--force : Bereits eingefrorene Monate neu berechnen}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Friert die Anerkennungen abgeschlossener Monate ein (ab dem 3. des Folgemonats)';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ohne Optionen werden alle noch offenen Monate seit dem ersten
|
||||||
|
* Anerkennungsmonat nachgezogen — der tägliche Lauf holt damit auch einen
|
||||||
|
* verpassten Stichtag nach. Ein neu gebauter Snapshot rechnet mit dem Stand
|
||||||
|
* des Ausführungstags; `--force` deshalb nur bewusst einsetzen.
|
||||||
|
*/
|
||||||
|
public function handle(RecognitionService $recognitions): int
|
||||||
|
{
|
||||||
|
$months = $this->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<int, array{month: int, year: int}>
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -98,6 +98,10 @@ class Kernel extends ConsoleKernel
|
||||||
// Incentive: Punkteberechnung täglich nach business:store-optimized
|
// Incentive: Punkteberechnung täglich nach business:store-optimized
|
||||||
$schedule->command('incentive:calculate')->dailyAt('05:00');
|
$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)
|
// Cleanup old log files weekly (keeps logs for 30 days)
|
||||||
$schedule->command('logs:cleanup --days=30')->weekly()->sundays()->at('05:00');
|
$schedule->command('logs:cleanup --days=30')->weekly()->sundays()->at('05:00');
|
||||||
|
|
||||||
|
|
|
||||||
53
app/Http/Controllers/User/RecognitionController.php
Normal file
53
app/Http/Controllers/User/RecognitionController.php
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\User;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Recognition\RecognitionFilterRequest;
|
||||||
|
use App\Services\Team\RecognitionService;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anerkennungen im Salescenter: oben die Ranglisten des gewählten
|
||||||
|
* abgeschlossenen Monats (Standard: Vormonat), darunter der Live-Stand des
|
||||||
|
* laufenden Monats. Sichtbar für alle Berater mit aktivem Konto.
|
||||||
|
*/
|
||||||
|
class RecognitionController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private RecognitionService $recognitions)
|
||||||
|
{
|
||||||
|
$this->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<int, array{month: int, year: int}> $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;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/Http/Requests/Recognition/RecognitionFilterRequest.php
Normal file
33
app/Http/Requests/Recognition/RecognitionFilterRequest.php
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Recognition;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class RecognitionFilterRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array<int, mixed>>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'period' => ['nullable', 'date_format:Y-m'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'period.date_format' => __('recognitions.period_invalid'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/Models/RecognitionSnapshot.php
Normal file
32
app/Models/RecognitionSnapshot.php
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Services\Team\RecognitionService;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eingefrorene Anerkennungen eines abgeschlossenen Monats
|
||||||
|
* ({@see RecognitionService::freeze()}).
|
||||||
|
*/
|
||||||
|
class RecognitionSnapshot extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'recognition_snapshots';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'year',
|
||||||
|
'month',
|
||||||
|
'payload',
|
||||||
|
'frozen_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'year' => 'int',
|
||||||
|
'month' => 'int',
|
||||||
|
'payload' => 'array',
|
||||||
|
'frozen_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
402
app/Services/Team/RecognitionService.php
Normal file
402
app/Services/Team/RecognitionService.php
Normal file
|
|
@ -0,0 +1,402 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Team;
|
||||||
|
|
||||||
|
use App\Models\RecognitionSnapshot;
|
||||||
|
use App\Models\UserSalesVolume;
|
||||||
|
use App\Services\Abo\TeamAboOverviewService;
|
||||||
|
use App\Services\Backoffice\SalesVolumePoints;
|
||||||
|
use App\User;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anerkennungen des Monats: firmenweite Ranglisten in fünf Kategorien, wie sie
|
||||||
|
* bisher monatlich per Mail verschickt wurden (dev/2026-08-12-intensiv-tickets).
|
||||||
|
*
|
||||||
|
* Die Kennzahlen folgen denselben Regeln wie die teaminterne Top-3-Karte
|
||||||
|
* ({@see TopResultsService}) und die VIP-Statistik, nur über alle Berater.
|
||||||
|
* Ein abgeschlossener Monat wird am {@see self::FREEZE_DAY}. des Folgemonats
|
||||||
|
* eingefroren ({@see self::freeze()}); bis dahin ist er vorläufig, damit
|
||||||
|
* Nachbuchungen und Nacht-Repairs noch einfließen.
|
||||||
|
*/
|
||||||
|
class RecognitionService
|
||||||
|
{
|
||||||
|
public const CATEGORIES = [
|
||||||
|
'recruiters',
|
||||||
|
'new_customer_abos',
|
||||||
|
'customer_abo_points',
|
||||||
|
'customer_abos_total',
|
||||||
|
'total_points',
|
||||||
|
];
|
||||||
|
|
||||||
|
public const FIRST_YEAR = 2026;
|
||||||
|
|
||||||
|
public const FIRST_MONTH = 1;
|
||||||
|
|
||||||
|
public const FREEZE_DAY = 3;
|
||||||
|
|
||||||
|
public const LIVE_LIMIT = 10;
|
||||||
|
|
||||||
|
private const DEFAULT_PODIUM_PLACES = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abweichende Platzzahl je Kategorie — die Mail führte beim
|
||||||
|
* 1.000-Punkte-Shop immer einen Platz 4 mit.
|
||||||
|
*/
|
||||||
|
private const PODIUM_PLACES = [
|
||||||
|
'total_points' => 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<string, array<int, array{place: int, value: float|int, members: array<int, array<string, mixed>>}>>}
|
||||||
|
*/
|
||||||
|
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<string, array<int, array<string, mixed>>>}
|
||||||
|
*/
|
||||||
|
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<int, array{month: int, year: int}>
|
||||||
|
*/
|
||||||
|
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<string, array<int, array<string, mixed>>>, rankings: array<string, array<int, array<string, mixed>>>}
|
||||||
|
*/
|
||||||
|
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<int, float|int> 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<int, float|int> $valuesByUserId
|
||||||
|
* @return array<int, array{user_id: int, first_name: string, last_name: string, value: float|int}>
|
||||||
|
*/
|
||||||
|
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<int, array{user_id: int, first_name: string, last_name: string, value: float|int}> $entries
|
||||||
|
* @return array<int, array{place: int, value: float|int, members: array<int, array{user_id: int, first_name: string, last_name: string}>}>
|
||||||
|
*/
|
||||||
|
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<int, array{user_id: int, first_name: string, last_name: string, value: float|int}> $entries
|
||||||
|
* @return array<int, array{rank: int, user_id: int, first_name: string, last_name: string, value: float|int}>
|
||||||
|
*/
|
||||||
|
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<string, array<int, array<string, mixed>>> $categories
|
||||||
|
* @return array<string, array<int, array<string, mixed>>>
|
||||||
|
*/
|
||||||
|
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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,4 +19,11 @@ return [
|
||||||
// Berater landen, sind User-Fehler über die Hauptwebseite — VP-Punkte
|
// Berater landen, sind User-Fehler über die Hauptwebseite — VP-Punkte
|
||||||
// stehen immer dem Berater zu (Kundenentscheid 2026-08-19).
|
// stehen immer dem Berater zu (Kundenentscheid 2026-08-19).
|
||||||
'company_user_id' => (int) env('MIVITA_COMPANY_USER_ID', 6),
|
'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'))
|
||||||
|
))),
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('recognition_snapshots', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
46
dev/2026-08-12-intensiv-tickets/september-anerkennungen.md
Normal file
46
dev/2026-08-12-intensiv-tickets/september-anerkennungen.md
Normal file
|
|
@ -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 |
|
||||||
|
|
@ -87,6 +87,7 @@ return [
|
||||||
'tools' => 'Tools',
|
'tools' => 'Tools',
|
||||||
'system_status' => 'Systemstatus',
|
'system_status' => 'Systemstatus',
|
||||||
'news_archive' => 'News Archiv',
|
'news_archive' => 'News Archiv',
|
||||||
|
'recognitions' => 'Anerkennungen',
|
||||||
'incentive' => 'Incentive',
|
'incentive' => 'Incentive',
|
||||||
'incentives' => 'Incentives',
|
'incentives' => 'Incentives',
|
||||||
'create' => 'Neu anlegen',
|
'create' => 'Neu anlegen',
|
||||||
|
|
|
||||||
52
resources/lang/de/recognitions.php
Normal file
52
resources/lang/de/recognitions.php
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => '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',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
@ -87,6 +87,7 @@ return [
|
||||||
'tools' => 'Tools',
|
'tools' => 'Tools',
|
||||||
'system_status' => 'System status',
|
'system_status' => 'System status',
|
||||||
'news_archive' => 'News Archive',
|
'news_archive' => 'News Archive',
|
||||||
|
'recognitions' => 'Recognitions',
|
||||||
'incentive' => 'Incentive',
|
'incentive' => 'Incentive',
|
||||||
'incentives' => 'Incentives',
|
'incentives' => 'Incentives',
|
||||||
'create' => 'Create new',
|
'create' => 'Create new',
|
||||||
|
|
|
||||||
52
resources/lang/en/recognitions.php
Normal file
52
resources/lang/en/recognitions.php
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => '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',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
@ -84,6 +84,7 @@ return [
|
||||||
'tools' => 'Herramientas',
|
'tools' => 'Herramientas',
|
||||||
'system_status' => 'Estado del sistema',
|
'system_status' => 'Estado del sistema',
|
||||||
'news_archive' => 'Archivo de Noticias',
|
'news_archive' => 'Archivo de Noticias',
|
||||||
|
'recognitions' => 'Reconocimientos',
|
||||||
'incentive' => 'Incentivo',
|
'incentive' => 'Incentivo',
|
||||||
'incentives' => 'Incentivos',
|
'incentives' => 'Incentivos',
|
||||||
'create' => 'Crear nuevo',
|
'create' => 'Crear nuevo',
|
||||||
|
|
|
||||||
52
resources/lang/es/recognitions.php
Normal file
52
resources/lang/es/recognitions.php
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => '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',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
@ -84,6 +84,7 @@ return [
|
||||||
'tools' => 'Outils',
|
'tools' => 'Outils',
|
||||||
'system_status' => 'État du système',
|
'system_status' => 'État du système',
|
||||||
'news_archive' => 'Archive des actualités',
|
'news_archive' => 'Archive des actualités',
|
||||||
|
'recognitions' => 'Distinctions',
|
||||||
'incentive' => 'Incentive',
|
'incentive' => 'Incentive',
|
||||||
'incentives' => 'Incentives',
|
'incentives' => 'Incentives',
|
||||||
'create' => 'Créer',
|
'create' => 'Créer',
|
||||||
|
|
|
||||||
52
resources/lang/fr/recognitions.php
Normal file
52
resources/lang/fr/recognitions.php
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => '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',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
@ -55,6 +55,12 @@
|
||||||
<div>{{ __('navigation.news_archive') }}</div>
|
<div>{{ __('navigation.news_archive') }}</div>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="sidenav-item{{ Request::is('user/recognitions') ? ' active' : '' }}">
|
||||||
|
<a href="{{ route('user_recognitions') }}" class="sidenav-link"><i
|
||||||
|
class="sidenav-icon ion ion-md-trophy"></i>
|
||||||
|
<div>{{ __('navigation.recognitions') }}</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
@if (Auth::user()->isVIP())
|
@if (Auth::user()->isVIP())
|
||||||
<li class="sidenav-item @if (Request::is('user/backoffice/statistics*')) open @endif">
|
<li class="sidenav-item @if (Request::is('user/backoffice/statistics*')) open @endif">
|
||||||
<a href="javascript:void(0)" class="sidenav-link sidenav-toggle">
|
<a href="javascript:void(0)" class="sidenav-link sidenav-toggle">
|
||||||
|
|
|
||||||
324
resources/views/user/recognitions/index.blade.php
Normal file
324
resources/views/user/recognitions/index.blade.php
Normal file
|
|
@ -0,0 +1,324 @@
|
||||||
|
@extends('layouts.layout-2')
|
||||||
|
|
||||||
|
@section('styles')
|
||||||
|
<style>
|
||||||
|
.recognition-hero {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: .75rem;
|
||||||
|
background: linear-gradient(135deg, #6b7758 0%, #4f5940 100%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recognition-hero .hero-trophy {
|
||||||
|
position: absolute;
|
||||||
|
right: -1rem;
|
||||||
|
bottom: -2.5rem;
|
||||||
|
font-size: 13rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: rgba(255, 255, 255, .08);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recognition-hero .hero-kicker {
|
||||||
|
letter-spacing: .08em;
|
||||||
|
color: #f4d77a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recognition-hero .badge-status {
|
||||||
|
background: rgba(255, 255, 255, .18);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recognition-card .card-header {
|
||||||
|
border-bottom: 0;
|
||||||
|
padding-bottom: .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recognition-card .category-icon {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recognition-place {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: .5rem;
|
||||||
|
padding: .6rem .75rem;
|
||||||
|
margin-bottom: .5rem;
|
||||||
|
background: #f7f7f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recognition-place.place-1 {
|
||||||
|
background: #fff7dc;
|
||||||
|
box-shadow: inset 3px 0 0 #e0b537;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recognition-place.place-2 {
|
||||||
|
background: #f1f3f5;
|
||||||
|
box-shadow: inset 3px 0 0 #a9b0b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recognition-place.place-3 {
|
||||||
|
background: #fbefe6;
|
||||||
|
box-shadow: inset 3px 0 0 #c98a5a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recognition-place .place-medal {
|
||||||
|
flex: 0 0 2.25rem;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
line-height: 1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recognition-place .place-number {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.8rem;
|
||||||
|
height: 1.8rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #dfe2d8;
|
||||||
|
color: #4f5940;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: .85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recognition-place .place-members {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recognition-place .place-member {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recognition-place .place-member + .place-member {
|
||||||
|
margin-top: .35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recognition-place .place-value {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
text-align: right;
|
||||||
|
padding-left: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recognition-live .live-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: .6rem;
|
||||||
|
height: .6rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #d9534f;
|
||||||
|
margin-right: .4rem;
|
||||||
|
animation: recognition-pulse 1.6s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes recognition-pulse {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(217, 83, 79, .6); }
|
||||||
|
70% { box-shadow: 0 0 0 .5rem rgba(217, 83, 79, 0); }
|
||||||
|
100% { box-shadow: 0 0 0 0 rgba(217, 83, 79, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.recognition-live .nav-pills .nav-link {
|
||||||
|
font-size: .85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recognition-live .nav-pills .nav-link.active,
|
||||||
|
.recognition-live .nav-pills .nav-link.active:hover,
|
||||||
|
.recognition-live .nav-pills .nav-link.active:focus {
|
||||||
|
background-color: #d7d700;
|
||||||
|
color: #565600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recognition-live .table td {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
@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
|
||||||
|
|
||||||
|
<h4 class="font-weight-bold py-2 mb-2 d-flex flex-wrap justify-content-between align-items-center w-100">
|
||||||
|
<div class="mb-2 mb-sm-0">
|
||||||
|
<i class="ion ion-md-trophy text-warning mr-1"></i>{{ __('recognitions.title') }}
|
||||||
|
</div>
|
||||||
|
@if ($periodOptions !== [])
|
||||||
|
<form method="GET" action="{{ route('user_recognitions') }}" class="form-inline">
|
||||||
|
<label for="period" class="small text-muted font-weight-normal mr-2">{{ __('recognitions.period_label') }}</label>
|
||||||
|
{{ html()->select('period', $periodOptions, $selectedPeriod)->id('period')->class('custom-select custom-select-sm mr-2')->attribute('onchange', 'this.form.submit()') }}
|
||||||
|
<noscript><button type="submit" class="btn btn-sm btn-primary">{{ __('recognitions.show') }}</button></noscript>
|
||||||
|
</form>
|
||||||
|
@endif
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
@if ($recognitions === null)
|
||||||
|
<div class="alert alert-info">{{ __('recognitions.no_months') }}</div>
|
||||||
|
@else
|
||||||
|
<div class="recognition-hero p-4 p-md-5 mb-4">
|
||||||
|
<i class="ion ion-md-trophy hero-trophy"></i>
|
||||||
|
<div class="position-relative">
|
||||||
|
<div class="hero-kicker text-uppercase small font-weight-bold mb-2">{{ __('recognitions.hero_kicker') }}</div>
|
||||||
|
<h2 class="font-weight-bold text-white mb-2">
|
||||||
|
{{ __('recognitions.hero_title', ['month' => HTMLHelper::getMonth($recognitions['month']).' '.$recognitions['year']]) }}
|
||||||
|
🥳💙
|
||||||
|
</h2>
|
||||||
|
<p class="mb-3" style="max-width: 46rem; opacity: .9;">{{ __('recognitions.hero_text') }}</p>
|
||||||
|
@if ($recognitions['is_final'])
|
||||||
|
<span class="badge badge-status px-2 py-1">
|
||||||
|
<i class="ion ion-md-lock mr-1"></i>{{ __('recognitions.status_final', ['date' => $recognitions['frozen_at']?->format('d.m.Y')]) }}
|
||||||
|
</span>
|
||||||
|
@else
|
||||||
|
<span class="badge badge-status px-2 py-1">
|
||||||
|
<i class="ion ion-md-time mr-1"></i>{{ __('recognitions.status_preliminary', ['date' => $recognitions['freeze_date']->format('d.m.Y')]) }}
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
@foreach (\App\Services\Team\RecognitionService::CATEGORIES as $category)
|
||||||
|
@php $podium = $recognitions['categories'][$category] ?? []; @endphp
|
||||||
|
<div class="col-md-6 col-xl-4 d-flex align-items-stretch mb-4">
|
||||||
|
<div class="card recognition-card w-100">
|
||||||
|
<div class="card-header bg-transparent d-flex align-items-start">
|
||||||
|
<span class="category-icon mr-2">{{ $categoryIcons[$category] }}</span>
|
||||||
|
<div>
|
||||||
|
<h6 class="font-weight-bold mb-1">{{ __('recognitions.categories.'.$category.'.title') }}</h6>
|
||||||
|
<div class="text-muted small">{{ __('recognitions.categories.'.$category.'.description') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body pt-2">
|
||||||
|
@forelse ($podium as $place)
|
||||||
|
<div class="recognition-place place-{{ $place['place'] }}">
|
||||||
|
<div class="place-medal">
|
||||||
|
@if (isset($medals[$place['place']]))
|
||||||
|
<span title="{{ __('recognitions.place', ['place' => $place['place']]) }}">{{ $medals[$place['place']] }}</span>
|
||||||
|
@else
|
||||||
|
<span class="place-number" title="{{ __('recognitions.place', ['place' => $place['place']]) }}">{{ $place['place'] }}</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<div class="place-members ml-2">
|
||||||
|
@foreach ($place['members'] as $member)
|
||||||
|
<div class="place-member">
|
||||||
|
@include('components._avatar', [
|
||||||
|
'imageUrl' => $member['profile_image_url'] ?? null,
|
||||||
|
'firstName' => $member['first_name'],
|
||||||
|
'lastName' => $member['last_name'],
|
||||||
|
'size' => 36,
|
||||||
|
])
|
||||||
|
<span class="ml-2 font-weight-semibold text-truncate">
|
||||||
|
{{ $member['first_name'] }} {{ $member['last_name'] }}
|
||||||
|
@if ((int) $member['user_id'] === $viewerId)
|
||||||
|
<span class="badge badge-pill badge-info ml-1">{{ __('recognitions.self') }}</span>
|
||||||
|
@endif
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
<div class="place-value">
|
||||||
|
<div class="font-weight-bold" style="font-size: 1.1rem;">{{ $formatValue($place['value']) }}</div>
|
||||||
|
<div class="text-muted small">{{ __('recognitions.categories.'.$category.'.unit') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<div class="text-muted small">{{ __('recognitions.empty') }}</div>
|
||||||
|
@endforelse
|
||||||
|
@if ($category === 'recruiters')
|
||||||
|
<div class="text-muted small mt-2">{{ __('recognitions.recruiters_min_hint') }}</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="card recognition-live mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-1 d-flex align-items-center">
|
||||||
|
<span class="live-dot"></span>
|
||||||
|
{{ __('recognitions.live_title', ['month' => HTMLHelper::getMonth($live['month']).' '.$live['year']]) }}
|
||||||
|
</h5>
|
||||||
|
<div class="text-muted small">
|
||||||
|
{{ __('recognitions.live_text', ['limit' => \App\Services\Team\RecognitionService::LIVE_LIMIT]) }}
|
||||||
|
· {{ __('recognitions.live_updated', ['time' => $live['calculated_at']->format('d.m.Y H:i')]) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<ul class="nav nav-pills flex-wrap mb-3" role="tablist">
|
||||||
|
@foreach (\App\Services\Team\RecognitionService::CATEGORIES as $category)
|
||||||
|
<li class="nav-item mb-1">
|
||||||
|
<a class="nav-link {{ $loop->first ? 'active' : '' }}" data-toggle="tab" href="#live-{{ $category }}" role="tab">
|
||||||
|
{{ $categoryIcons[$category] }} {{ __('recognitions.categories.'.$category.'.title') }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
<div class="tab-content">
|
||||||
|
@foreach (\App\Services\Team\RecognitionService::CATEGORIES as $category)
|
||||||
|
<div class="tab-pane fade {{ $loop->first ? 'show active' : '' }}" id="live-{{ $category }}" role="tabpanel">
|
||||||
|
@if (empty($live['categories'][$category]))
|
||||||
|
<div class="text-muted small">{{ __('recognitions.live_empty') }}</div>
|
||||||
|
@else
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 5rem;">{{ __('recognitions.live_rank') }}</th>
|
||||||
|
<th>{{ __('recognitions.live_name') }}</th>
|
||||||
|
<th class="text-right">{{ __('recognitions.categories.'.$category.'.unit') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach ($live['categories'][$category] as $row)
|
||||||
|
<tr class="{{ (int) $row['user_id'] === $viewerId ? 'table-info' : '' }}">
|
||||||
|
<td class="font-weight-bold">
|
||||||
|
{{ $medals[$row['rank']] ?? $row['rank'].'.' }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
@include('components._avatar', [
|
||||||
|
'imageUrl' => $row['profile_image_url'] ?? null,
|
||||||
|
'firstName' => $row['first_name'],
|
||||||
|
'lastName' => $row['last_name'],
|
||||||
|
'size' => 32,
|
||||||
|
])
|
||||||
|
<span class="ml-2">
|
||||||
|
{{ $row['first_name'] }} {{ $row['last_name'] }}
|
||||||
|
@if ((int) $row['user_id'] === $viewerId)
|
||||||
|
<span class="badge badge-pill badge-info ml-1">{{ __('recognitions.self') }}</span>
|
||||||
|
@endif
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-right font-weight-bold">{{ $formatValue($row['value']) }}</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
|
|
@ -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', 'User\BackofficeStatisticsController@export')->name('user_backoffice_statistics_export');
|
||||||
Route::get('/user/backoffice/statistics/export-overview', 'User\BackofficeStatisticsController@overviewExport')->name('user_backoffice_statistics_overview_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
|
// user team
|
||||||
Route::get('/user/team/add/member', 'User\TeamController@addMember')->name('user_team_add_member');
|
Route::get('/user/team/add/member', 'User\TeamController@addMember')->name('user_team_add_member');
|
||||||
|
|
||||||
|
|
|
||||||
115
tests/Feature/RecognitionPageTest.php
Normal file
115
tests/Feature/RecognitionPageTest.php
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\RecognitionSnapshot;
|
||||||
|
use App\Models\UserAccount;
|
||||||
|
use App\Models\UserSalesVolume;
|
||||||
|
use App\User;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
uses(TestCase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->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('<tr class="table-info">', 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('<option value="2026-08" selected="selected">', false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('faellt bei einem nicht abgeschlossenen Monat auf den Vormonat zurueck', function () {
|
||||||
|
$this->actingAs(recPageUser('Vera', 'Viewer'))->get(route('user_recognitions', ['period' => '2026-10']))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Unsere Erfolge im September 2026');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lehnt ein ungueltiges Monatsformat ab', function () {
|
||||||
|
$this->actingAs(recPageUser('Vera', 'Viewer'))->get(route('user_recognitions', ['period' => 'kaputt']))
|
||||||
|
->assertRedirect()
|
||||||
|
->assertSessionHasErrors('period');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sperrt die Seite fuer Berater ohne aktives Konto', function () {
|
||||||
|
$this->actingAs(recPageUser('Ina', 'Inaktiv', ['payment_account' => now()->subDay()]))
|
||||||
|
->get(route('user_recognitions'))
|
||||||
|
->assertRedirect('/home');
|
||||||
|
});
|
||||||
253
tests/Feature/RecognitionServiceTest.php
Normal file
253
tests/Feature/RecognitionServiceTest.php
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\RecognitionSnapshot;
|
||||||
|
use App\Models\ShoppingOrder;
|
||||||
|
use App\Models\UserAbo;
|
||||||
|
use App\Models\UserAccount;
|
||||||
|
use App\Models\UserSalesVolume;
|
||||||
|
use App\Services\Team\RecognitionService;
|
||||||
|
use App\User;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
uses(TestCase::class, RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
Carbon::setTestNow(Carbon::parse('2026-10-09 10:00:00'));
|
||||||
|
Cache::flush();
|
||||||
|
config(['mivita.recognition_excluded_user_ids' => []]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(function () {
|
||||||
|
Carbon::setTestNow();
|
||||||
|
});
|
||||||
|
|
||||||
|
function recMakeUser(string $firstName, string $lastName = 'Partner', ?int $sponsorId = null, ?string $partnerPaidAt = null): User
|
||||||
|
{
|
||||||
|
$account = UserAccount::forceCreate([
|
||||||
|
'first_name' => $firstName,
|
||||||
|
'last_name' => $lastName,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return User::forceCreate([
|
||||||
|
'email' => 'rec-'.uniqid('', true).'@example.com',
|
||||||
|
'password' => bcrypt('secret'),
|
||||||
|
'lang' => 'de',
|
||||||
|
'm_level' => 1,
|
||||||
|
'm_sponsor' => $sponsorId,
|
||||||
|
'payment_account' => now()->addYear(),
|
||||||
|
'active_date' => $partnerPaidAt,
|
||||||
|
'partner_paid_at' => $partnerPaidAt,
|
||||||
|
'account_id' => $account->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recAddVolume(int $userId, float $points, int $status, int $month = 9, ?int $shoppingOrderId = null): void
|
||||||
|
{
|
||||||
|
UserSalesVolume::forceCreate([
|
||||||
|
'user_id' => $userId,
|
||||||
|
'month' => $month,
|
||||||
|
'year' => 2026,
|
||||||
|
'points' => $points,
|
||||||
|
'status' => $status,
|
||||||
|
'shopping_order_id' => $shoppingOrderId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recAddCustomerAboPoints(int $userId, float $points, int $month = 9): void
|
||||||
|
{
|
||||||
|
$order = ShoppingOrder::forceCreate([
|
||||||
|
'shopping_user_id' => 0,
|
||||||
|
'auth_user_id' => 0,
|
||||||
|
'country_id' => 0,
|
||||||
|
'user_shop_id' => 0,
|
||||||
|
'member_id' => $userId,
|
||||||
|
'is_abo' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
recAddVolume($userId, $points, 2, $month, $order->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recMakeCustomerAbo(int $consultantId, string $startDate): UserAbo
|
||||||
|
{
|
||||||
|
return UserAbo::create([
|
||||||
|
'user_id' => 0,
|
||||||
|
'member_id' => $consultantId,
|
||||||
|
'shopping_user_id' => 0,
|
||||||
|
'email' => 'rec-abo-'.uniqid('', true).'@example.com',
|
||||||
|
'is_for' => 'ot',
|
||||||
|
'payone_userid' => 0,
|
||||||
|
'clearingtype' => 'cc',
|
||||||
|
'amount' => 5000,
|
||||||
|
'active' => true,
|
||||||
|
'status' => 2,
|
||||||
|
'abo_interval' => 5,
|
||||||
|
'start_date' => $startDate,
|
||||||
|
'next_date' => '2026-11-05',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $podium
|
||||||
|
* @return array<int, array{place: int, value: float|int, names: array<int, string>}>
|
||||||
|
*/
|
||||||
|
function recPlaces(array $podium): array
|
||||||
|
{
|
||||||
|
return array_map(fn (array $place) => [
|
||||||
|
'place' => $place['place'],
|
||||||
|
'value' => $place['value'],
|
||||||
|
'names' => array_map(fn (array $member) => $member['first_name'], $place['members']),
|
||||||
|
], $podium);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('rankt alle fuenf Kategorien firmenweit, unabhaengig vom Team', function (): void {
|
||||||
|
$anna = recMakeUser('Anna');
|
||||||
|
$ben = recMakeUser('Ben');
|
||||||
|
|
||||||
|
recMakeUser('Neu1', 'Partner', $anna->id, '2026-09-10');
|
||||||
|
recMakeUser('Neu2', 'Partner', $anna->id, '2026-09-20');
|
||||||
|
recMakeUser('Alt', 'Partner', $anna->id, '2026-08-20');
|
||||||
|
|
||||||
|
recMakeCustomerAbo($ben->id, '2026-09-05');
|
||||||
|
recMakeCustomerAbo($ben->id, '2026-09-15');
|
||||||
|
recMakeCustomerAbo($ben->id, '2026-06-01');
|
||||||
|
recMakeCustomerAbo($anna->id, '2026-09-02');
|
||||||
|
|
||||||
|
recAddCustomerAboPoints($ben->id, 120.5);
|
||||||
|
recAddCustomerAboPoints($anna->id, 80);
|
||||||
|
recAddVolume($anna->id, 900, 1);
|
||||||
|
recAddVolume($ben->id, 50, 1, 8);
|
||||||
|
|
||||||
|
$categories = app(RecognitionService::class)->recognitions(9, 2026)['categories'];
|
||||||
|
|
||||||
|
expect(recPlaces($categories['recruiters']))->toBe([['place' => 1, 'value' => 2, 'names' => ['Anna']]])
|
||||||
|
->and(recPlaces($categories['new_customer_abos']))->toBe([
|
||||||
|
['place' => 1, 'value' => 2, 'names' => ['Ben']],
|
||||||
|
['place' => 2, 'value' => 1, 'names' => ['Anna']],
|
||||||
|
])
|
||||||
|
->and(recPlaces($categories['customer_abos_total']))->toBe([
|
||||||
|
['place' => 1, 'value' => 3, 'names' => ['Ben']],
|
||||||
|
['place' => 2, 'value' => 1, 'names' => ['Anna']],
|
||||||
|
])
|
||||||
|
->and(recPlaces($categories['customer_abo_points']))->toBe([
|
||||||
|
['place' => 1, 'value' => 120.5, 'names' => ['Ben']],
|
||||||
|
['place' => 2, 'value' => 80.0, 'names' => ['Anna']],
|
||||||
|
])
|
||||||
|
->and(recPlaces($categories['total_points']))->toBe([
|
||||||
|
['place' => 1, 'value' => 980.0, 'names' => ['Anna']],
|
||||||
|
['place' => 2, 'value' => 120.5, 'names' => ['Ben']],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fuehrt Firmenkonto und Grosskunden nie auf', function (): void {
|
||||||
|
$company = recMakeUser('Firma', 'GmbH');
|
||||||
|
$bigCustomer = recMakeUser('Ruth', 'Gross');
|
||||||
|
$anna = recMakeUser('Anna');
|
||||||
|
config(['mivita.recognition_excluded_user_ids' => [$company->id, $bigCustomer->id]]);
|
||||||
|
|
||||||
|
recAddVolume($company->id, 5000, 2);
|
||||||
|
recAddVolume($bigCustomer->id, 7000, 1);
|
||||||
|
recAddVolume($anna->id, 300, 1);
|
||||||
|
|
||||||
|
$service = app(RecognitionService::class);
|
||||||
|
|
||||||
|
expect(recPlaces($service->recognitions(9, 2026)['categories']['total_points']))
|
||||||
|
->toBe([['place' => 1, 'value' => 300.0, 'names' => ['Anna']]])
|
||||||
|
->and(array_column($service->liveRankings(10, 2026)['categories']['total_points'], 'user_id'))
|
||||||
|
->not->toContain($company->id, $bigCustomer->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('teilt Gleichstaende einen Platz und vergibt die Plaetze lueckenlos', function (): void {
|
||||||
|
foreach (['Carla' => 500, 'Anna' => 400, 'Ben' => 400, 'Dora' => 300, 'Emil' => 200, 'Fritz' => 100] as $name => $points) {
|
||||||
|
recAddVolume(recMakeUser($name)->id, $points, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$categories = app(RecognitionService::class)->recognitions(9, 2026)['categories'];
|
||||||
|
|
||||||
|
// 1.000-Punkte-Shop mit Platz 4 (wie in der Mail), danach ist Schluss.
|
||||||
|
expect(recPlaces($categories['total_points']))->toBe([
|
||||||
|
['place' => 1, 'value' => 500.0, 'names' => ['Carla']],
|
||||||
|
['place' => 2, 'value' => 400.0, 'names' => ['Anna', 'Ben']],
|
||||||
|
['place' => 3, 'value' => 300.0, 'names' => ['Dora']],
|
||||||
|
['place' => 4, 'value' => 200.0, 'names' => ['Emil']],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('platziert Recruiter erst ab zwei Neupartnern', function (): void {
|
||||||
|
$anna = recMakeUser('Anna');
|
||||||
|
$ben = recMakeUser('Ben');
|
||||||
|
recMakeUser('Neu1', 'Partner', $anna->id, '2026-09-10');
|
||||||
|
recMakeUser('Neu2', 'Partner', $anna->id, '2026-09-11');
|
||||||
|
recMakeUser('Neu3', 'Partner', $ben->id, '2026-09-12');
|
||||||
|
|
||||||
|
$service = app(RecognitionService::class);
|
||||||
|
|
||||||
|
expect(recPlaces($service->recognitions(9, 2026)['categories']['recruiters']))
|
||||||
|
->toBe([['place' => 1, 'value' => 2, 'names' => ['Anna']]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zeigt Live-Ranglisten mit Wettkampf-Rang und hoechstens zehn Zeilen', function (): void {
|
||||||
|
foreach (range(1, 12) as $index) {
|
||||||
|
recAddVolume(recMakeUser('User'.str_pad((string) $index, 2, '0', STR_PAD_LEFT))->id, $index === 2 ? 1200 : 100 * $index, 1, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = app(RecognitionService::class)->liveRankings(10, 2026)['categories']['total_points'];
|
||||||
|
|
||||||
|
expect($rows)->toHaveCount(10)
|
||||||
|
->and(array_column(array_slice($rows, 0, 3), 'rank'))->toBe([1, 1, 3])
|
||||||
|
->and(array_column(array_slice($rows, 0, 3), 'first_name'))->toBe(['User02', 'User12', 'User11'])
|
||||||
|
->and($rows[0])->toHaveKey('profile_image_url');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('liefert vor dem Stichtag den vorlaeufigen Stand und danach den eingefrorenen', function (): void {
|
||||||
|
$anna = recMakeUser('Anna');
|
||||||
|
recAddVolume($anna->id, 300, 1);
|
||||||
|
|
||||||
|
Carbon::setTestNow(Carbon::parse('2026-10-02 12:00:00'));
|
||||||
|
$service = app(RecognitionService::class);
|
||||||
|
$preliminary = $service->recognitions(9, 2026);
|
||||||
|
|
||||||
|
expect($preliminary['is_final'])->toBeFalse()
|
||||||
|
->and($preliminary['freeze_date']->toDateString())->toBe('2026-10-03');
|
||||||
|
|
||||||
|
Carbon::setTestNow(Carbon::parse('2026-10-03 06:15:00'));
|
||||||
|
$this->artisan('recognitions:freeze', ['--month' => 9, '--year' => 2026])->assertSuccessful();
|
||||||
|
|
||||||
|
// Spaetere Buchungen veraendern den festgeschriebenen Monat nicht mehr.
|
||||||
|
recAddVolume(recMakeUser('Spaet')->id, 9999, 1);
|
||||||
|
Cache::flush();
|
||||||
|
|
||||||
|
$final = $service->recognitions(9, 2026);
|
||||||
|
|
||||||
|
expect($final['is_final'])->toBeTrue()
|
||||||
|
->and($final['frozen_at']->toDateString())->toBe('2026-10-03')
|
||||||
|
->and(recPlaces($final['categories']['total_points']))->toEqual([['place' => 1, 'value' => 300, 'names' => ['Anna']]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('friert per Command nur festschreibbare Monate ein und ueberschreibt nichts ohne --force', function (): void {
|
||||||
|
$anna = recMakeUser('Anna');
|
||||||
|
recAddVolume($anna->id, 300, 1);
|
||||||
|
|
||||||
|
Carbon::setTestNow(Carbon::parse('2026-10-02 06:15:00'));
|
||||||
|
$this->artisan('recognitions:freeze')->assertSuccessful();
|
||||||
|
|
||||||
|
expect(RecognitionSnapshot::query()->where('year', 2026)->where('month', 9)->exists())->toBeFalse()
|
||||||
|
->and(RecognitionSnapshot::query()->where('year', 2026)->where('month', 8)->exists())->toBeTrue()
|
||||||
|
->and(RecognitionSnapshot::query()->count())->toBe(8);
|
||||||
|
|
||||||
|
Carbon::setTestNow(Carbon::parse('2026-10-03 06:15:00'));
|
||||||
|
$this->artisan('recognitions:freeze')->assertSuccessful();
|
||||||
|
$frozenAt = RecognitionSnapshot::query()->where('month', 9)->value('frozen_at');
|
||||||
|
|
||||||
|
Carbon::setTestNow(Carbon::parse('2026-10-04 06:15:00'));
|
||||||
|
$this->artisan('recognitions:freeze')->assertSuccessful();
|
||||||
|
|
||||||
|
expect(RecognitionSnapshot::query()->count())->toBe(9)
|
||||||
|
->and(RecognitionSnapshot::query()->where('month', 9)->value('frozen_at'))->toEqual($frozenAt);
|
||||||
|
|
||||||
|
$this->artisan('recognitions:freeze', ['--month' => 9, '--year' => 2026, '--force' => true])->assertSuccessful();
|
||||||
|
|
||||||
|
expect(RecognitionSnapshot::query()->where('month', 9)->first()->frozen_at->toDateString())->toBe('2026-10-04');
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue