$participants * * @method static \Illuminate\Database\Eloquent\Builder|Incentive newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|Incentive newQuery() * @method static \Illuminate\Database\Eloquent\Builder|Incentive query() * * @mixin \Eloquent */ class Incentive extends Model { use HasFactory, Sluggable, SoftDeletes; protected $table = 'incentives'; protected $casts = [ 'trans_name' => 'array', 'trans_subtitle' => 'array', 'trans_description' => 'array', 'trans_terms' => 'array', 'points_partner_onetime' => 'int', 'points_abo_onetime' => 'int', 'min_direct_partners' => 'int', 'min_customer_abos' => 'int', 'max_winners' => 'int', 'amount_per_partner' => 'float', 'bonus_rank_1' => 'float', 'bonus_rank_2' => 'float', 'bonus_rank_3' => 'float', 'include_sales_points' => 'bool', 'include_abos' => 'bool', 'status' => 'int', 'vip_only' => 'bool', 'qualification_start' => 'date', 'qualification_end' => 'date', 'calculation_end' => 'date', ]; protected $fillable = [ 'name', 'trans_name', 'subtitle', 'trans_subtitle', 'description', 'trans_description', 'image', 'terms', 'trans_terms', 'qualification_start', 'qualification_end', 'calculation_end', 'points_partner_onetime', 'points_abo_onetime', 'min_direct_partners', 'min_customer_abos', 'max_winners', 'amount_per_partner', 'bonus_rank_1', 'bonus_rank_2', 'bonus_rank_3', 'include_sales_points', 'include_abos', 'status', 'vip_only', ]; /** * Anzahl der Plaetze, fuer die ein Rang-Bonus hinterlegt werden kann. */ public const RANK_BONUS_SLOTS = 3; public static $statusTypes = [ 0 => 'draft', 1 => 'active', 2 => 'closed', ]; public static $statusColors = [ 0 => 'warning', 1 => 'success', 2 => 'secondary', ]; public function sluggable(): array { return [ 'slug' => [ 'source' => 'name', ], ]; } // Relationships public function participants() { return $this->hasMany(IncentiveParticipant::class); } // Scopes public function scopeActive($query) { return $query->where('status', 1); } /** * Incentives, die dieser Berater sehen darf: VIP sieht alles, andere nur ohne VIP-Sperre. */ public function scopeVisibleFor($query, ?User $user) { if ($user?->isVIP()) { return $query; } return $query->where('vip_only', false); } /** * Incentives, bei denen Umsatzpunkte der Neupartner/Neuabos mitzaehlen. */ public function scopeIncludingSalesPoints($query) { return $query->where('include_sales_points', true); } public function scopeInQualificationPeriod($query, ?Carbon $date = null) { $date = $date ?: Carbon::now(); return $query->where('qualification_start', '<=', $date) ->where('qualification_end', '>=', $date); } public function scopeInCalculationPeriod($query, ?Carbon $date = null) { $date = $date ?: Carbon::now(); return $query->where('qualification_start', '<=', $date) ->where('calculation_end', '>=', $date); } /** * Incentives, deren Qualifikationsfenster das Datum enthaelt (Datumsvergleich auf Tagesebene, * damit auch Zeitstempel am letzten Fenstertag zaehlen). */ public function scopeQualificationWindowContains($query, Carbon $date) { return $query->whereDate('qualification_start', '<=', $date) ->whereDate('qualification_end', '>=', $date); } // Helpers /** * Aktive Incentives fuer Sidenav und Dashboard: mehrere koennen parallel laufen * (z. B. Montenegro bis Oktober, Recruiting-Challenge ab Oktober), aelteste zuerst. * * @return Collection */ public static function activeForNavigation(?User $user = null): Collection { return static::active() ->visibleFor($user) ->orderBy('qualification_start') ->orderBy('id') ->get(); } /** * Ordner der Bilder dieses Incentives (relativ zu public/): img/incentive/. */ public function imageDirectory(): string { return 'img/incentive/'.$this->slug; } /** * Pfad des Titelbilds relativ zu public/, bevorzugt aus dem eigenen Ordner, * sonst aus dem Hauptordner (Altbestand). NULL ohne Titelbild. */ public function heroImagePath(): ?string { if (! $this->image) { return null; } $own = $this->imageDirectory().'/'.$this->image; if (is_file(public_path($own))) { return $own; } return 'img/incentive/'.$this->image; } /** * Galerie-Bilder nur aus dem eigenen Ordner, ohne Titelbild, sortiert. * Der Hauptordner zaehlt bewusst nicht mehr, damit Bilder eines anderen * Incentives nicht unter diesem erscheinen. * * @return list */ public function galleryImages(): array { $dir = public_path($this->imageDirectory()); if (! is_dir($dir)) { return []; } $files = glob($dir.'/*.{jpg,jpeg,png,webp}', GLOB_BRACE) ?: []; $images = []; foreach ($files as $file) { $basename = basename($file); if ($basename === $this->image) { continue; } $images[] = $this->imageDirectory().'/'.$basename; } sort($images); return $images; } /** * Darf dieser Berater das Incentive sehen (Teaser, Rangliste, Details, Teilnahme)? */ public function isVisibleFor(?User $user): bool { return ! $this->vip_only || (bool) $user?->isVIP(); } public function isActive(): bool { return $this->status === 1; } public function isDraft(): bool { return $this->status === 0; } public function isClosed(): bool { return $this->status === 2; } public function isInQualificationPeriod(?Carbon $date = null): bool { $date = $date ?: Carbon::now(); return $date->between($this->qualification_start, $this->qualification_end); } public function isInCalculationPeriod(?Carbon $date = null): bool { $date = $date ?: Carbon::now(); return $date->between($this->qualification_start, $this->calculation_end); } public function isDateInScope(int $month, int $year): bool { $date = Carbon::createFromDate($year, $month, 1); return $date->between( $this->qualification_start->copy()->startOfMonth(), $this->calculation_end->copy()->endOfMonth() ); } public function getStatusType(): string { return isset(self::$statusTypes[$this->status]) ? __('incentive.status_'.self::$statusTypes[$this->status]) : ''; } public function getStatusColor(): string { return self::$statusColors[$this->status] ?? 'default'; } /** * Wird eine Geldpraemie je Neupartner ausgezahlt (Recruiting-Challenge)? */ public function hasCashPayout(): bool { return (float) $this->amount_per_partner > 0 || $this->rankBonuses() !== []; } /** * Rang-Boni je Platz (nur gesetzte Betraege > 0, Platz => Betrag). * * @return array */ public function rankBonuses(): array { $bonuses = []; for ($rank = 1; $rank <= self::RANK_BONUS_SLOTS; $rank++) { $amount = (float) $this->{'bonus_rank_'.$rank}; if ($amount > 0) { $bonuses[$rank] = $amount; } } return $bonuses; } /** * Rang-Bonus fuer einen Platz, 0 wenn keiner hinterlegt oder der Platz ausserhalb der Gewinnerplaetze liegt. */ public function rankBonus(?int $rank): float { if ($rank === null || $rank < 1 || $rank > $this->max_winners) { return 0.0; } return $this->rankBonuses()[$rank] ?? 0.0; } /** * Zaehlen Umsatzpunkte der Neupartner/Neuabos mit? Aus = Wertung nur nach Einmalpunkten. */ public function includesSalesPoints(): bool { return $this->include_sales_points === null || (bool) $this->include_sales_points; } /** * Werden Abos ueberhaupt erfasst? Aus = reine Recruiting-Challenge, weder Kunden- * noch Eigenabos landen im Tracking. */ public function includesAbos(): bool { return $this->include_abos === null || (bool) $this->include_abos; } /** * Gibt es eine Mindestqualifikation (direkte Partner oder Kundenabos)? */ public function hasMinimumRequirements(): bool { return $this->min_direct_partners > 0 || $this->min_customer_abos > 0; } /** * Spielen Abos in diesem Incentive eine Rolle (Punkte oder Mindestanzahl)? */ public function usesAbos(): bool { return $this->includesAbos() && ($this->points_abo_onetime > 0 || $this->min_customer_abos > 0); } /** * Rangliste nach Anzahl Neupartner: Einmalpunkte je Partner, keine Abo- und Umsatzpunkte. */ public function ranksByPartnerCount(): bool { return ! $this->includesSalesPoints() && ! $this->usesAbos() && $this->points_partner_onetime > 0; } /** * Rangliste nach Umsatzpunkten der Neupartner: keine Einmal- und keine Abopunkte * (Recruiting-Challenge ab 08.10.2026, Berechnung wie Montenegro). */ public function ranksBySalesOfNewPartners(): bool { return $this->includesSalesPoints() && ! $this->usesAbos() && $this->points_partner_onetime <= 0; } /** * Darstellung als Challenge mit Geldpraemie (Praemie je Neupartner, Top-Bonus) statt Punktesystem. */ public function isCashChallenge(): bool { return $this->hasCashPayout() && ($this->ranksByPartnerCount() || $this->ranksBySalesOfNewPartners()); } /** * Get specific translation for a field and locale. */ public function getTrans(string $key, string $lang): string { $transKey = 'trans_'.$key; if (! empty($this->{$transKey}[$lang])) { return $this->{$transKey}[$lang]; } return ''; } /** * Get translated value for the current locale, falling back to German (default). */ public function getLang(string $key): string { $lang = \App::getLocale(); if ($lang === 'de') { return (string) ($this->{$key} ?? ''); } $trans = $this->getTrans($key, $lang); if ($trans !== '') { return $trans; } return (string) ($this->{$key} ?? ''); } /** * Qualifikationsfenster als Zeitstempel-Paar: [Start 00:00:00, Ende 23:59:59]. * Zentrale Fensterberechnung, damit Bestellungen am letzten Fenstertag ueberall zaehlen. * * @return array{0: Carbon, 1: Carbon} */ public function qualificationWindow(): array { return [ $this->qualification_start->copy()->startOfDay(), $this->qualification_end->copy()->endOfDay(), ]; } /** * Liegt der Zeitstempel im Qualifikationsfenster (inkl. des kompletten letzten Tages)? */ public function qualificationWindowContains(Carbon $date): bool { [$windowStart, $windowEnd] = $this->qualificationWindow(); return $date->between($windowStart, $windowEnd); } /** * @return array */ public function getCalculationMonths(): array { $months = []; $current = $this->qualification_start->copy()->startOfMonth(); $end = $this->calculation_end->copy()->startOfMonth(); while ($current->lte($end)) { $months[] = [ 'month' => $current->month, 'year' => $current->year, ]; $current->addMonth(); } return $months; } }