Kundenentscheid 25.09.2026: - Storno: Ist jede bezahlte Starterpaket-Bestellung eines Neupartners storniert (txaction = cancelled), bekommt das Tracking cancelled_at, eine Storno-Logzeile nimmt die Einmalpunkte zurueck. Nachtlauf und Neuaufbau markieren; "Meine Berechnung" zeigt den Partner durchgestrichen mit "Storniert am ..., zaehlt nicht". - Gezaehlt wird fuer alle, einen Rang bekommen nur bestaetigte Teilnehmer; Export und Summen nur fuer sie. Unbestaetigte sehen "Deine Praemie nach Bestaetigung", im Admin "Teilnahme nicht bestaetigt, keine Auszahlung". - Datenschutz: andere Berater sehen Unbestaetigte in der Rangliste nicht mehr (auch nicht anonym), VIP-Konten schon, der eigene Eintrag bleibt. - Teilnahmebedingungen Punkt 2 und 7 und Hinweistexte in de/en/es/fr. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
447 lines
19 KiB
PHP
447 lines
19 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Incentive;
|
|
|
|
use App\Models\Incentive;
|
|
use App\Models\IncentiveNewAbo;
|
|
use App\Models\IncentiveNewPartner;
|
|
use App\Models\IncentiveParticipant;
|
|
use App\Models\IncentivePointsLog;
|
|
use App\Models\ShoppingOrder;
|
|
use App\Models\UserAbo;
|
|
use App\Models\UserAboOrder;
|
|
use App\Models\UserSalesVolume;
|
|
use App\User;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class IncentiveTracker
|
|
{
|
|
/**
|
|
* Track a new partner registration (Starterpaket bezahlt).
|
|
* Fuegt Partner in Tracking-Tabelle ein + Log-Eintrag + Neuberechnung.
|
|
*/
|
|
public static function trackNewPartner(ShoppingOrder $shopping_order): void
|
|
{
|
|
try {
|
|
if (! $shopping_order->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<int> $participantIds
|
|
* @return array<int, int> 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,
|
|
]);
|
|
}
|
|
}
|