qualifiesForIncentiveTrackedPartner()) { return; } $new_user = User::find($shopping_order->auth_user_id); if (! $new_user || ! $new_user->m_sponsor) { return; } $sponsor_id = $new_user->m_sponsor; // Neupartner zaehlen im Moment der ersten Kontozahlung, nicht der Bestellung // (Kundenentscheid 16.09.2026, wie in der Statistik): wer Ende September // bestellt und am 1. Oktober bezahlt, zaehlt ab Oktober. $registration_date = self::newPartnerCountedAt($new_user); $incentives = Incentive::query() ->active() ->qualificationWindowContains($registration_date) ->get(); foreach ($incentives as $incentive) { $participant = IncentiveParticipant::ensureForIncentiveUser($incentive, $sponsor_id); // Tracking-Tabelle: Partner erfassen (keine Duplikate) $newPartner = IncentiveNewPartner::firstOrCreate( ['participant_id' => $participant->id, 'user_id' => $new_user->id], ['registered_at' => $registration_date] ); // Log-Eintrag (Audit-Trail, keine Duplikate) self::writeLog($participant, 'partner', User::class, $new_user->id, $new_user->getFullName() ?: $new_user->email ?: ('User #'.$new_user->id), $registration_date, $incentive->points_partner_onetime, $newPartner->id); // Neuberechnung aus Tracking-Tabellen $participant->recalculateFromTrackingTables()->save(); self::updateRanking($incentive); } } catch (\Throwable $e) { Log::error('IncentiveTracker::trackNewPartner error: '.$e->getMessage(), [ 'shopping_order_id' => $shopping_order->id, ]); } } /** * Zeitpunkt, zu dem ein Neupartner fuer ein Incentive zaehlt: seine erste * Kontozahlung (`users.partner_paid_at`, {@see User::scopeNewPartnerIn()}). Im * Zahlungspfad ist der Wert schon gesetzt; fehlt er ausnahmsweise, ist jetzt der * Zahlungsmoment. */ public static function newPartnerCountedAt(User $partner): Carbon { return $partner->partner_paid_at ? Carbon::parse($partner->partner_paid_at) : Carbon::now(); } /** * Track an abo activation (Kundenabo is_for=ot oder Berater-Eigenabo is_for=me, bezahlt + aktiv). * Fuegt Abo in Tracking-Tabelle ein + Log-Eintrag + Neuberechnung. */ /** * Berater-ID für Incentive-Zuordnung: Bei Kundenabos (ot) sitzt der Berater in member_id, * bei Berater-Eigenabo (me) in user_id (vgl. AboHelper::createNewAbo). */ public static function consultantUserIdForAboIncentive(UserAbo $user_abo): ?int { if ($user_abo->is_for === 'ot') { return $user_abo->member_id ? (int) $user_abo->member_id : null; } return $user_abo->user_id ? (int) $user_abo->user_id : null; } public static function trackAboActivated(ShoppingOrder $shopping_order): void { try { $user_abo = $shopping_order->getUserAbo(); if (! $user_abo) { return; } if ($user_abo->is_for === 'ot') { $consultant_id = self::consultantUserIdForAboIncentive($user_abo); } elseif ($user_abo->is_for === 'me') { $consultant_id = $user_abo->user_id ? (int) $user_abo->user_id : null; } else { return; } if (! $consultant_id) { return; } $activation_date = $shopping_order->created_at ?? Carbon::now(); $incentives = Incentive::query() ->active() ->qualificationWindowContains($activation_date) ->get() ->filter(fn (Incentive $incentive) => $incentive->includesAbos()); foreach ($incentives as $incentive) { $participant = IncentiveParticipant::ensureForIncentiveUser($incentive, $consultant_id); // Tracking-Tabelle: Abo erfassen (keine Duplikate) $newAbo = IncentiveNewAbo::firstOrCreate( ['participant_id' => $participant->id, 'user_abo_id' => $user_abo->id], ['activated_at' => $activation_date] ); // Log-Eintrag (Audit-Trail) self::writeLog($participant, 'abo', get_class($user_abo), $user_abo->id, $user_abo->email ?: ('Abo #'.$user_abo->id), $activation_date, $incentive->points_abo_onetime, null, $newAbo->id); // Neuberechnung aus Tracking-Tabellen $participant->recalculateFromTrackingTables()->save(); self::updateRanking($incentive); } } catch (\Throwable $e) { Log::error('IncentiveTracker::trackAboActivated error: '.$e->getMessage(), [ 'shopping_order_id' => $shopping_order->id, ]); } } /** * Track accumulated sales volume points. * Punkte werden NUR gezaehlt wenn der Umsatz von einem gettrackten * Neupartner oder Neuabo stammt. Incentives mit `include_sales_points = 0` * (Recruiting-Challenge) bekommen keine Umsatzpunkte. */ public static function trackSalesVolume(UserSalesVolume $user_sales_volume): void { try { $month = $user_sales_volume->month; $year = $user_sales_volume->year; if (! $month || ! $year) { return; } $points = (int) abs($user_sales_volume->points ?? 0); if ($points <= 0) { return; } // A. Pruefen ob der User ein gettrackter Neupartner ist $partner_trackings = IncentiveNewPartner::where('user_id', $user_sales_volume->user_id) ->whereHas('participant.incentive', fn ($q) => $q->active()->includingSalesPoints()) ->with('participant.incentive') ->get(); foreach ($partner_trackings as $tracking) { $participant = $tracking->participant; $incentive = $participant->incentive; if (! $incentive->isDateInScope($month, $year)) { continue; } $exists = IncentivePointsLog::where('participant_id', $participant->id) ->where('user_sales_volume_id', $user_sales_volume->id) ->where('is_storno', false) ->exists(); if (! $exists) { IncentivePointsLog::create([ 'participant_id' => $participant->id, 'type' => 'partner', 'source_type' => UserSalesVolume::class, 'source_id' => $user_sales_volume->id, 'source_label' => $user_sales_volume->message ?? ('SV '.$month.'/'.$year), 'month' => $month, 'year' => $year, 'points_onetime' => 0, 'points_accumulated' => $points, 'user_sales_volume_id' => $user_sales_volume->id, 'incentive_new_partner_id' => $tracking->id, ]); } $participant->recalculateFromTrackingTables()->save(); self::updateRanking($incentive); } // B. Pruefen ob die Bestellung zu einem getrackten Neuabo gehoert (Kundenabo ot oder Berater me). // Bei Verlaengerung weicht shopping_order.shopping_user_id oft vom Stamm-user_abos.shopping_user_id ab (Replikat). if ($user_sales_volume->shopping_order_id) { $order = ShoppingOrder::find($user_sales_volume->shopping_order_id); if ($order) { // Bewusst inkl. geloeschter Abos: ein bereits getracktes Abo kann // geloescht/ersetzt worden sein, waehrend Umsaetze nachgebucht werden // (z. B. extern erledigte Lieferungen) - die zaehlen weiterhin. $userAboFromOrder = UserAboOrder::where('shopping_order_id', $order->id) ->first()?->user_abo()->withTrashed()->first(); if (! $userAboFromOrder || ! in_array($userAboFromOrder->is_for, ['ot', 'me'], true)) { $userAboFromOrder = null; } $abo_trackings = $userAboFromOrder ? IncentiveNewAbo::query() ->where('user_abo_id', $userAboFromOrder->id) ->whereHas('participant.incentive', fn ($q) => $q->active()->includingSalesPoints()) ->with('participant.incentive') ->get() : collect(); foreach ($abo_trackings as $tracking) { $participant = $tracking->participant; $incentive = $participant->incentive; if (! $incentive->isDateInScope($month, $year)) { continue; } $exists = IncentivePointsLog::where('participant_id', $participant->id) ->where('user_sales_volume_id', $user_sales_volume->id) ->where('is_storno', false) ->exists(); if (! $exists) { IncentivePointsLog::create([ 'participant_id' => $participant->id, 'type' => 'abo', 'source_type' => UserSalesVolume::class, 'source_id' => $user_sales_volume->id, 'source_label' => $user_sales_volume->message ?? ('SV '.$month.'/'.$year), 'month' => $month, 'year' => $year, 'points_onetime' => 0, 'points_accumulated' => $points, 'user_sales_volume_id' => $user_sales_volume->id, 'incentive_new_abo_id' => $tracking->id, ]); } $participant->recalculateFromTrackingTables()->save(); self::updateRanking($incentive); } } } } catch (\Throwable $e) { Log::error('IncentiveTracker::trackSalesVolume error: '.$e->getMessage(), [ 'user_sales_volume_id' => $user_sales_volume->id, ]); } } /** * Track a storno (cancellation) of a sales volume. * Storno-Log + Neuberechnung aus Tracking-Tabellen. */ public static function trackStorno(UserSalesVolume $original, UserSalesVolume $cancellation): void { try { // Storno-Log-Eintraege schreiben $original_logs = IncentivePointsLog::where('user_sales_volume_id', $original->id) ->where('is_storno', false) ->get(); $affected_participants = collect(); foreach ($original_logs as $original_log) { IncentivePointsLog::create([ 'participant_id' => $original_log->participant_id, 'type' => $original_log->type, 'source_type' => $original_log->source_type, 'source_id' => $original_log->source_id, 'source_label' => 'Storno: '.$original_log->source_label, 'month' => $cancellation->month ?? $original_log->month, 'year' => $cancellation->year ?? $original_log->year, 'points_onetime' => -$original_log->points_onetime, 'points_accumulated' => -$original_log->points_accumulated, 'is_storno' => true, 'storno_of_id' => $original_log->id, 'user_sales_volume_id' => $cancellation->id, 'incentive_new_partner_id' => $original_log->incentive_new_partner_id, 'incentive_new_abo_id' => $original_log->incentive_new_abo_id, ]); $affected_participants->push($original_log->participant_id); } // Auch ohne Log-Eintraege: alle Teilnehmer dieses Users neu berechnen if ($affected_participants->isEmpty() && $original->user_id) { $affected_participants = IncentiveParticipant::whereHas('incentive', function ($q) { $q->active(); })->where('user_id', $original->user_id)->pluck('id'); } // Neuberechnung fuer alle betroffenen Teilnehmer foreach ($affected_participants->unique() as $participant_id) { $participant = IncentiveParticipant::with('incentive')->find($participant_id); if (! $participant) { continue; } $participant->recalculateFromTrackingTables()->save(); if ($participant->incentive) { self::updateRanking($participant->incentive); } } } catch (\Throwable $e) { Log::error('IncentiveTracker::trackStorno error: '.$e->getMessage(), [ 'original_id' => $original->id, 'cancellation_id' => $cancellation->id, ]); } } /** * Update ranking for all participants of an incentive. * Der Rang entspricht der Anzeigereihenfolge des Leaderboards: Qualifizierte zuerst (1..n), * dahinter Nicht-Qualifizierte, jeweils nach Punkten absteigend. * * Einen Rang bekommen nur Teilnehmer mit bestaetigter Teilnahme. Bei Punktgleichstand * liegt vorn, wer den Stand zuerst erreicht hat (Teilnahmebedingungen Recruiting-Challenge, Punkt 6), zuletzt die * Teilnehmer-ID, damit die Reihenfolge nie von der Datenbank abhaengt. */ public static function updateRanking(Incentive $incentive): void { // Nur Teilnehmer mit Punkten und bestaetigter Teilnahme bekommen einen Rang // (Kundenentscheid 25.09.2026: Unbestaetigte zaehlen im Hintergrund mit, stehen // aber nicht in der Rangliste und belegen keinen Gewinnerplatz). $with_points = IncentiveParticipant::where('incentive_id', $incentive->id) ->where('total_points', '>', 0) ->whereNotNull('accepted_terms_at') ->get(); $reached_at = self::pointsReachedAtByParticipant($incentive, $with_points->pluck('id')->all()); $with_points = $with_points->sort(function (IncentiveParticipant $a, IncentiveParticipant $b) use ($reached_at): int { return [(int) $b->is_qualified, (int) $b->total_points, $reached_at[$a->id] ?? PHP_INT_MAX, $a->id] <=> [(int) $a->is_qualified, (int) $a->total_points, $reached_at[$b->id] ?? PHP_INT_MAX, $b->id]; }); $rank = 1; foreach ($with_points as $participant) { $participant->rank = $rank; $participant->save(); $rank++; } // Teilnehmer ohne Punkte oder ohne Bestaetigung: Rang entfernen IncentiveParticipant::where('incentive_id', $incentive->id) ->where(fn ($query) => $query->where('total_points', '<=', 0)->orWhereNull('accepted_terms_at')) ->whereNotNull('rank') ->update(['rank' => null]); } /** * Zeitpunkt, zu dem ein Teilnehmer seinen heutigen Stand erreicht hat: der letzte * zaehlende Neupartner bzw. das letzte zaehlende Neuabo. Grundlage sind die fachlichen * Zeitpunkte der Tracking-Tabellen (registered_at / activated_at), nicht die * Log-Zeitstempel — ein Neuaufbau (rebuildFromSourceTables) schreibt die Logs neu. * * @param list $participantIds * @return array Teilnehmer-ID => Unix-Zeitstempel */ private static function pointsReachedAtByParticipant(Incentive $incentive, array $participantIds): array { if ($participantIds === []) { return []; } $reached = []; $sources = [ [IncentiveNewPartner::class, 'registered_at', (int) $incentive->points_partner_onetime], [IncentiveNewAbo::class, 'activated_at', (int) $incentive->points_abo_onetime], ]; foreach ($sources as [$model, $column, $points]) { if ($points <= 0) { continue; } $latest = $model::query() ->whereIn('participant_id', $participantIds) ->when($model === IncentiveNewPartner::class, fn ($query) => $query->whereNull('cancelled_at')) ->groupBy('participant_id') ->selectRaw("participant_id, MAX({$column}) as reached_at") ->pluck('reached_at', 'participant_id'); foreach ($latest as $participantId => $timestamp) { $reached[$participantId] = max($reached[$participantId] ?? 0, Carbon::parse($timestamp)->getTimestamp()); } } return $reached; } /** * Log-Eintrag schreiben (Audit-Trail, keine Duplikate). */ private static function writeLog(IncentiveParticipant $participant, string $type, string $source_type, int $source_id, string $source_label, Carbon $date, int $points_onetime, ?int $incentive_new_partner_id = null, ?int $incentive_new_abo_id = null): void { $exists = IncentivePointsLog::where('participant_id', $participant->id) ->where('type', $type) ->where('source_type', $source_type) ->where('source_id', $source_id) ->where('is_storno', false) ->exists(); if ($exists) { return; } IncentivePointsLog::create([ 'participant_id' => $participant->id, 'type' => $type, 'source_type' => $source_type, 'source_id' => $source_id, 'source_label' => $source_label, 'month' => $date->month, 'year' => $date->year, 'points_onetime' => $points_onetime, 'points_accumulated' => 0, 'incentive_new_partner_id' => $incentive_new_partner_id, 'incentive_new_abo_id' => $incentive_new_abo_id, ]); } }