$pointsLog * @property-read Collection $newPartners * @property-read Collection $newAbos * * @method static \Illuminate\Database\Eloquent\Builder|IncentiveParticipant newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|IncentiveParticipant newQuery() * @method static \Illuminate\Database\Eloquent\Builder|IncentiveParticipant orderByIncentiveLeaderboard() * @method static \Illuminate\Database\Eloquent\Builder|IncentiveParticipant orderByRankNullsLast() * @method static \Illuminate\Database\Eloquent\Builder|IncentiveParticipant query() * @method static \Illuminate\Database\Eloquent\Builder|IncentiveParticipant withRankingActivity() * * @mixin \Eloquent */ class IncentiveParticipant extends Model { use HasFactory; protected $table = 'incentive_participants'; protected $casts = [ 'incentive_id' => 'int', 'user_id' => 'int', 'total_points' => 'int', 'qualified_partners' => 'int', 'qualified_abos' => 'int', 'is_qualified' => 'bool', 'rank' => 'int', 'accepted_terms_at' => 'datetime', ]; protected $fillable = [ 'incentive_id', 'user_id', 'accepted_terms_at', 'total_points', 'qualified_partners', 'qualified_abos', 'is_qualified', 'rank', ]; // Relationships public function incentive() { return $this->belongsTo(Incentive::class); } public function user() { return $this->belongsTo(User::class); } public function pointsLog() { return $this->hasMany(IncentivePointsLog::class, 'participant_id'); } public function newPartners() { return $this->hasMany(IncentiveNewPartner::class, 'participant_id'); } public function newAbos() { return $this->hasMany(IncentiveNewAbo::class, 'participant_id'); } // Scopes public function scopeQualified($query) { return $query->where('is_qualified', true); } /** * Teilnehmer mit nachweisbarer Aktivität: mindestens ein qualifizierter Partner, ein Kunden-Abo oder * Gesamtpunkte größer null. Reine Nullstände erscheinen in der User-Rangliste nicht. */ public function scopeWithRankingActivity(Builder $query): Builder { $model = $query->getModel(); $qualifiedPartners = $model->qualifyColumn('qualified_partners'); $qualifiedAbos = $model->qualifyColumn('qualified_abos'); $totalPoints = $model->qualifyColumn('total_points'); return $query->where(function (Builder $q) use ($qualifiedPartners, $qualifiedAbos, $totalPoints) { $q->where($qualifiedPartners, '>', 0) ->orWhere($qualifiedAbos, '>', 0) ->orWhere($totalPoints, '>', 0); }); } /** * Leaderboard: qualifizierte Teilnehmer (Mindest-Partner/Abos) oben, unabhängig von den Punkten; * darunter nach Rang (1, 2, …), ohne Rang zuletzt; gleiche Stufe nach Gesamtpunkten absteigend. * Bei Punktgleichstand: Teilnehmer mit bestätigter Teilnahme (Klarnamen) vor anonymen. */ public function scopeOrderByIncentiveLeaderboard(Builder $query): Builder { $model = $query->getModel(); $grammar = $model->getConnection()->getQueryGrammar(); $qualifiedRank = $grammar->wrap( $model->qualifyColumn('rank') ); $acceptedTerms = $grammar->wrap( $model->qualifyColumn('accepted_terms_at') ); return $query ->orderBy($model->qualifyColumn('is_qualified'), 'desc') ->orderByRaw($qualifiedRank.' IS NULL') ->orderBy($model->qualifyColumn('rank'), 'asc') ->orderBy($model->qualifyColumn('total_points'), 'desc') ->orderByRaw($acceptedTerms.' IS NOT NULL DESC'); } /** * Sortierung nach Rang (1, 2, …); Teilnehmer ohne gesetzten Rang stehen unten. */ public function scopeOrderByRankNullsLast(Builder $query): Builder { $model = $query->getModel(); $qualifiedRank = $model->getConnection()->getQueryGrammar()->wrap( $model->qualifyColumn('rank') ); return $query ->orderByRaw($qualifiedRank.' IS NULL') ->orderBy($model->qualifyColumn('rank'), 'asc'); } public function scopeWinners($query, int $maxWinners) { return $query->qualified() ->whereNotNull('rank') ->where('rank', '<=', $maxWinners) ->orderBy('rank'); } // Helpers public function hasAcceptedTerms(): bool { return $this->accepted_terms_at !== null; } /** * Teilnehmerzeile fuer einen Berater anlegen, falls noch nicht vorhanden (ohne Teilnahme-Bestaetigung). */ public static function ensureForIncentiveUser(Incentive $incentive, int $userId): self { return self::firstOrCreate( [ 'incentive_id' => $incentive->id, 'user_id' => $userId, ], [ 'accepted_terms_at' => null, ] ); } /** * Alle Berater (User mit m_level) als Teilnehmer anlegen, damit Punkte im Qualifikationszeitraum ohne Checkbox mitlaufen. * * @return int Anzahl neu angelegter Zeilen */ public static function ensureConsultantsForIncentive(Incentive $incentive): int { $added = 0; User::query() ->where('id', '!=', 1) ->whereNull('deleted_at') ->where('admin', '<', 4) ->whereNotNull('m_level') ->whereNotExists(function ($q) use ($incentive) { $q->selectRaw('1') ->from('incentive_participants') ->whereColumn('incentive_participants.user_id', 'users.id') ->where('incentive_participants.incentive_id', $incentive->id); }) ->orderBy('id') ->chunkById(500, function ($users) use ($incentive, &$added) { foreach ($users as $user) { self::create([ 'incentive_id' => $incentive->id, 'user_id' => $user->id, 'accepted_terms_at' => null, ]); $added++; } }); return $added; } public function checkQualification(): bool { $incentive = $this->incentive; $this->is_qualified = $this->qualified_partners >= $incentive->min_direct_partners && $this->qualified_abos >= $incentive->min_customer_abos; return $this->is_qualified; } /** * Berechnung aus Tracking-Tabellen und Points-Log. * Zaehlt Partner/Abos aus eigenen Tabellen, Punkte aus Log. */ public function recalculateFromTrackingTables(): self { $this->qualified_partners = $this->newPartners()->count(); $this->qualified_abos = $this->newAbos()->count(); $this->total_points = (int) $this->pointsLog() ->selectRaw('COALESCE(SUM(points_onetime + points_accumulated), 0) as total') ->value('total'); $this->checkQualification(); return $this; } /** * Kompletter Neuaufbau aus Quelldaten (Users, UserAbos, UserSalesVolumes). * Loescht Tracking-Tabellen + Log und baut alles neu auf. * Nur fuer Batch/Cron/Force-Rebuild. */ public function rebuildFromSourceTables(): self { $incentive = $this->incentive; // Tracking-Tabellen + Log leeren $this->newPartners()->delete(); $this->newAbos()->delete(); $this->pointsLog()->delete(); // A. Neupartner: direkt gesponserte User im Qualifikationszeitraum mit bezahltem Starterpaket $new_partners = User::where('m_sponsor', $this->user_id) ->whereBetween('created_at', [ $incentive->qualification_start, $incentive->qualification_end->copy()->endOfDay(), ]) ->whereHas('shopping_orders', function ($q) { $q->wherePaidRegistrationIncludesStarterKit(); }) ->get(); foreach ($new_partners as $partner) { $newPartner = IncentiveNewPartner::create([ 'participant_id' => $this->id, 'user_id' => $partner->id, 'registered_at' => $partner->created_at, ]); IncentivePointsLog::create([ 'participant_id' => $this->id, 'type' => 'partner', 'source_type' => User::class, 'source_id' => $partner->id, 'source_label' => $partner->getFullName() ?: $partner->email ?: ('User #'.$partner->id), 'month' => $partner->created_at->month, 'year' => $partner->created_at->year, 'points_onetime' => $incentive->points_partner_onetime, 'points_accumulated' => 0, 'incentive_new_partner_id' => $newPartner->id, ]); } // B. Kundenabos (ot) + Berater-Eigenabos (me): status=2 $qualStart = $incentive->qualification_start->copy()->startOfDay(); $qualEnd = $incentive->qualification_end->copy()->endOfDay(); // Kundenabos: Berater steht in member_id (nicht user_id) $customerAbosInPeriod = UserAbo::where('member_id', $this->user_id) ->where('is_for', 'ot') ->where('status', 2) ->whereBetween('created_at', [$qualStart, $qualEnd]) ->get(); // Eigenabo: im Qualifikationszeitraum neu abgeschlossen $ownAbosInPeriod = UserAbo::where('user_id', $this->user_id) ->where('is_for', 'me') ->where('status', 2) ->whereBetween('created_at', [$qualStart, $qualEnd]) ->get(); // Eigenabo: bereits vor Qualifikationsbeginn aktiv → einmalig mit Wirkung ab Qualifikationsstart $ownAbosPreExisting = UserAbo::where('user_id', $this->user_id) ->where('is_for', 'me') ->where('status', 2) ->where('created_at', '<', $qualStart) ->get(); foreach ($customerAbosInPeriod->concat($ownAbosInPeriod)->concat($ownAbosPreExisting) as $abo) { $activatedAt = $abo->created_at; $logMonth = (int) $abo->created_at->month; $logYear = (int) $abo->created_at->year; if ($abo->is_for === 'me' && $abo->created_at->lt($qualStart)) { $activatedAt = $qualStart->copy(); $logMonth = (int) $qualStart->month; $logYear = (int) $qualStart->year; } $newAbo = IncentiveNewAbo::create([ 'participant_id' => $this->id, 'user_abo_id' => $abo->id, 'activated_at' => $activatedAt, ]); IncentivePointsLog::create([ 'participant_id' => $this->id, 'type' => 'abo', 'source_type' => UserAbo::class, 'source_id' => $abo->id, 'source_label' => $abo->email ?: ('Abo #'.$abo->id), 'month' => $logMonth, 'year' => $logYear, 'points_onetime' => $incentive->points_abo_onetime, 'points_accumulated' => 0, 'incentive_new_abo_id' => $newAbo->id, ]); } // C. Akkumulierte Punkte NUR von Neupartnern und Neuabos $calculation_months = $incentive->getCalculationMonths(); $new_partner_user_ids = $this->newPartners()->pluck('user_id')->toArray(); $abo_shopping_user_ids = UserAbo::whereIn('id', $this->newAbos()->pluck('user_abo_id')) ->whereNotNull('shopping_user_id') ->pluck('shopping_user_id') ->toArray(); $newPartnerByUserId = $this->newPartners()->get()->keyBy('user_id'); $newAboByUserAboId = $this->newAbos()->get()->keyBy('user_abo_id'); foreach ($calculation_months as $period) { // C1. Neupartner-Umsaetze: Sales Volumes der Neupartner selbst if (! empty($new_partner_user_ids)) { $partner_svs = UserSalesVolume::whereIn('user_id', $new_partner_user_ids) ->where('month', $period['month']) ->where('year', $period['year']) ->where('status', '!=', 6) ->get(); foreach ($partner_svs as $sv) { $points = (int) abs($sv->points ?? 0); if ($points <= 0) { continue; } IncentivePointsLog::create([ 'participant_id' => $this->id, 'type' => 'partner', 'source_type' => UserSalesVolume::class, 'source_id' => $sv->id, 'source_label' => $sv->message ?? ('SV '.$period['month'].'/'.$period['year']), 'month' => $period['month'], 'year' => $period['year'], 'points_onetime' => 0, 'points_accumulated' => $points, 'user_sales_volume_id' => $sv->id, 'incentive_new_partner_id' => $newPartnerByUserId->get($sv->user_id)?->id, ]); } // Stornos von Neupartnern $partner_stornos = UserSalesVolume::whereIn('user_id', $new_partner_user_ids) ->where('month', $period['month']) ->where('year', $period['year']) ->where('status', 6) ->get(); foreach ($partner_stornos as $storno_sv) { $points = (int) abs($storno_sv->points ?? 0); if ($points <= 0) { continue; } IncentivePointsLog::create([ 'participant_id' => $this->id, 'type' => 'partner', 'source_type' => UserSalesVolume::class, 'source_id' => $storno_sv->id, 'source_label' => 'Storno: '.($storno_sv->message ?? 'SV #'.$storno_sv->id), 'month' => $period['month'], 'year' => $period['year'], 'points_onetime' => 0, 'points_accumulated' => -$points, 'is_storno' => true, 'user_sales_volume_id' => $storno_sv->id, 'incentive_new_partner_id' => $newPartnerByUserId->get($storno_sv->user_id)?->id, ]); } } // C2. Neuabo-Umsaetze: Sales Volumes von Bestellungen der Abo-Kunden if (! empty($abo_shopping_user_ids)) { $abo_svs = UserSalesVolume::where('user_id', $this->user_id) ->where('month', $period['month']) ->where('year', $period['year']) ->where('status', '!=', 6) ->whereHas('shopping_order', fn ($q) => $q->whereIn('shopping_user_id', $abo_shopping_user_ids)) ->get(); foreach ($abo_svs as $sv) { $points = (int) abs($sv->points ?? 0); if ($points <= 0) { continue; } $incentiveNewAboId = null; if ($sv->shopping_order_id) { $userAboId = UserAboOrder::where('shopping_order_id', $sv->shopping_order_id)->value('user_abo_id'); if ($userAboId) { $incentiveNewAboId = $newAboByUserAboId->get($userAboId)?->id; } } IncentivePointsLog::create([ 'participant_id' => $this->id, 'type' => 'abo', 'source_type' => UserSalesVolume::class, 'source_id' => $sv->id, 'source_label' => $sv->message ?? ('SV '.$period['month'].'/'.$period['year']), 'month' => $period['month'], 'year' => $period['year'], 'points_onetime' => 0, 'points_accumulated' => $points, 'user_sales_volume_id' => $sv->id, 'incentive_new_abo_id' => $incentiveNewAboId, ]); } // Stornos von Abo-Kunden $abo_stornos = UserSalesVolume::where('user_id', $this->user_id) ->where('month', $period['month']) ->where('year', $period['year']) ->where('status', 6) ->whereHas('shopping_order', fn ($q) => $q->whereIn('shopping_user_id', $abo_shopping_user_ids)) ->get(); foreach ($abo_stornos as $storno_sv) { $points = (int) abs($storno_sv->points ?? 0); if ($points <= 0) { continue; } $incentiveNewAboId = null; if ($storno_sv->shopping_order_id) { $userAboId = UserAboOrder::where('shopping_order_id', $storno_sv->shopping_order_id)->value('user_abo_id'); if ($userAboId) { $incentiveNewAboId = $newAboByUserAboId->get($userAboId)?->id; } } IncentivePointsLog::create([ 'participant_id' => $this->id, 'type' => 'abo', 'source_type' => UserSalesVolume::class, 'source_id' => $storno_sv->id, 'source_label' => 'Storno: '.($storno_sv->message ?? 'SV #'.$storno_sv->id), 'month' => $period['month'], 'year' => $period['year'], 'points_onetime' => 0, 'points_accumulated' => -$points, 'is_storno' => true, 'user_sales_volume_id' => $storno_sv->id, 'incentive_new_abo_id' => $incentiveNewAboId, ]); } } } // Totals aus den neu aufgebauten Tracking-Tabellen berechnen return $this->recalculateFromTrackingTables(); } /** * @deprecated Verwende recalculateFromTrackingTables() stattdessen */ public function recalculatePoints(): int { $this->recalculateFromTrackingTables(); return $this->total_points; } public function isWinner(): bool { if (! $this->is_qualified || $this->rank === null) { return false; } return $this->rank <= $this->incentive->max_winners; } private static function determineLogType(UserSalesVolume $usv): string { if ($usv->status_turnover == 2 || $usv->status_points == 2) { return 'abo'; } return 'partner'; } }