From 1885402a6c5707d3a89a94b89a4b8e4284cd3c61 Mon Sep 17 00:00:00 2001 From: Kevin Date: Wed, 23 Sep 2026 15:44:24 +0000 Subject: [PATCH] Incentive (E1): Gleichstand nach "zuerst erreicht", Neupartner zaehlen im Monat der Zahlung - Rangliste: bei gleichen Punkten bestaetigte Teilnahme vor anonym, danach wer den Stand zuerst erreicht hat (letzter zaehlender Neupartner bzw. Neuabo laut Tracking-Tabellen), zuletzt die Teilnehmer-ID. Vorher entschied bei Gleichstand die Datenbankreihenfolge (Teilnahmebedingungen Recruiting-Challenge, Punkt 6). - Neupartner zaehlen fuer Incentives ab ihrer ersten Kontozahlung (users.partner_paid_at), wie in der Statistik (Kundenentscheid 16.09.2026), nicht ab dem Bestelldatum: Live-Tracking, naechtliche Reparatur und Neuaufbau nutzen dieselbe Regel (IncentiveTracker::newPartnerCountedAt). Wer am 29.09. bestellt und am 01.10. zahlt, zaehlt in der Challenge. Montenegro ist abgeschlossen; gegen die Live-Kopie tauschen dort nur Plaetze ab 81, und der naechtliche Lauf entfernt keine bestehenden Trackings. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/Models/IncentiveParticipant.php | 24 ++---- .../IncentivePointsLogRepairService.php | 11 +-- app/Services/Incentive/IncentiveTracker.php | 72 ++++++++++++++-- ...ncentivePartnerRepairSponsorChangeTest.php | 47 ++++++++++- .../Incentive/IncentiveRankingTest.php | 82 +++++++++++++++++++ 5 files changed, 207 insertions(+), 29 deletions(-) diff --git a/app/Models/IncentiveParticipant.php b/app/Models/IncentiveParticipant.php index 07f94525..7ffa2ee5 100644 --- a/app/Models/IncentiveParticipant.php +++ b/app/Models/IncentiveParticipant.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Services\Incentive\IncentiveTracker; use App\User; use Carbon\Carbon; use Illuminate\Database\Eloquent\Builder; @@ -265,29 +266,20 @@ class IncentiveParticipant extends Model $this->newAbos()->delete(); $this->pointsLog()->delete(); - // A. Neupartner: direkt gesponserte User mit im Qualifikationszeitraum bezahltem Starterpaket - // (Bestelldatum zaehlt, wie Live-Tracking - nicht das User-Registrierungsdatum) + // A. Neupartner: direkt gesponserte User mit bezahltem Starterpaket, deren erste + // Kontozahlung (partner_paid_at) im Qualifikationszeitraum liegt — wie Live-Tracking + // und Statistik (Kundenentscheid 16.09.2026), nicht das Bestelldatum. [$partnerWindowStart, $partnerWindowEnd] = $incentive->qualificationWindow(); $new_partners = User::where('m_sponsor', $this->user_id) - ->whereHas('shopping_orders', function ($q) use ($partnerWindowStart, $partnerWindowEnd) { - $q->wherePaidRegistrationIncludesStarterKit() - ->whereBetween('created_at', [$partnerWindowStart, $partnerWindowEnd]); + ->whereBetween('partner_paid_at', [$partnerWindowStart, $partnerWindowEnd]) + ->whereHas('shopping_orders', function ($q) { + $q->wherePaidRegistrationIncludesStarterKit(); }) ->get(); - $firstStarterOrderDates = $new_partners->isNotEmpty() - ? ShoppingOrder::query() - ->whereIn('auth_user_id', $new_partners->pluck('id')) - ->wherePaidRegistrationIncludesStarterKit() - ->whereBetween('created_at', [$partnerWindowStart, $partnerWindowEnd]) - ->groupBy('auth_user_id') - ->selectRaw('auth_user_id, MIN(created_at) as first_order_at') - ->pluck('first_order_at', 'auth_user_id') - : collect(); - foreach ($new_partners as $partner) { - $registeredAt = Carbon::parse($firstStarterOrderDates->get($partner->id)); + $registeredAt = IncentiveTracker::newPartnerCountedAt($partner); $newPartner = IncentiveNewPartner::create([ 'participant_id' => $this->id, diff --git a/app/Services/Incentive/IncentivePointsLogRepairService.php b/app/Services/Incentive/IncentivePointsLogRepairService.php index 8c7f184d..96ae851b 100644 --- a/app/Services/Incentive/IncentivePointsLogRepairService.php +++ b/app/Services/Incentive/IncentivePointsLogRepairService.php @@ -36,11 +36,13 @@ class IncentivePointsLogRepairService [$windowStart, $windowEnd] = $incentive->qualificationWindow(); + // Neupartner im Fenster = erste Kontozahlung im Fenster (partner_paid_at), mit + // bezahltem Starterpaket — wie das Live-Tracking (IncentiveTracker::newPartnerCountedAt). $candidates = User::query() ->where('m_sponsor', $participant->user_id) - ->whereHas('shopping_orders', function ($q) use ($windowStart, $windowEnd) { - $q->wherePaidRegistrationIncludesStarterKit() - ->whereBetween('created_at', [$windowStart, $windowEnd]); + ->whereBetween('partner_paid_at', [$windowStart, $windowEnd]) + ->whereHas('shopping_orders', function ($q) { + $q->wherePaidRegistrationIncludesStarterKit(); }) ->get(); @@ -48,7 +50,6 @@ class IncentivePointsLogRepairService ? ShoppingOrder::query() ->whereIn('auth_user_id', $candidates->pluck('id')) ->wherePaidRegistrationIncludesStarterKit() - ->whereBetween('created_at', [$windowStart, $windowEnd]) ->orderBy('id') ->get() ->groupBy('auth_user_id') @@ -73,7 +74,7 @@ class IncentivePointsLogRepairService ->where('participant_id', $participant->id) ->where('user_id', $partner->id) ->exists()) { - $registeredAt = $order?->created_at ?? $partner->created_at; + $registeredAt = IncentiveTracker::newPartnerCountedAt($partner); $newPartner = IncentiveNewPartner::create([ 'participant_id' => $participant->id, diff --git a/app/Services/Incentive/IncentiveTracker.php b/app/Services/Incentive/IncentiveTracker.php index ddb37d9a..67128df5 100644 --- a/app/Services/Incentive/IncentiveTracker.php +++ b/app/Services/Incentive/IncentiveTracker.php @@ -34,7 +34,10 @@ class IncentiveTracker } $sponsor_id = $new_user->m_sponsor; - $registration_date = $shopping_order->created_at ?? Carbon::now(); + // 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() @@ -65,6 +68,17 @@ class IncentiveTracker } } + /** + * 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. @@ -322,17 +336,25 @@ class IncentiveTracker * 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. + * + * Bei Punktgleichstand: Teilnahme bestaetigt vor anonym, danach 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 bekommen einen Rang (bei Punktgleichstand: Teilnahme bestaetigt vor anonym) + // Nur Teilnehmer mit Punkten bekommen einen Rang $with_points = IncentiveParticipant::where('incentive_id', $incentive->id) ->where('total_points', '>', 0) - ->orderByDesc('is_qualified') - ->orderByDesc('total_points') - ->orderByRaw('accepted_terms_at IS NOT NULL DESC') ->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, (int) $b->hasAcceptedTerms(), $reached_at[$a->id] ?? PHP_INT_MAX, $a->id] + <=> [(int) $a->is_qualified, (int) $a->total_points, (int) $a->hasAcceptedTerms(), $reached_at[$b->id] ?? PHP_INT_MAX, $b->id]; + }); + $rank = 1; foreach ($with_points as $participant) { $participant->rank = $rank; @@ -347,6 +369,46 @@ class IncentiveTracker ->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) + ->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). */ diff --git a/tests/Feature/Incentive/IncentivePartnerRepairSponsorChangeTest.php b/tests/Feature/Incentive/IncentivePartnerRepairSponsorChangeTest.php index 5ca0b395..050efd1b 100644 --- a/tests/Feature/Incentive/IncentivePartnerRepairSponsorChangeTest.php +++ b/tests/Feature/Incentive/IncentivePartnerRepairSponsorChangeTest.php @@ -47,7 +47,13 @@ function repairTestCreateUser(?int $sponsorId = null, ?string $createdAt = null) return $user->fresh(); } -function repairTestCreateStarterKitOrder(User $registrant, User $shopOwner, ?string $orderCreatedAt = null): ShoppingOrder +/** + * Bezahlte Registrierung mit Starterpaket. Wie im Zahlungspfad bekommt der Partner + * dabei seine erste Kontozahlung (`partner_paid_at`) — standardmaessig zum + * Bestellzeitpunkt, mit `$paidAt` spaeter (Bestellung Ende Monat, Zahlung Anfang + * Folgemonat). + */ +function repairTestCreateStarterKitOrder(User $registrant, User $shopOwner, ?string $orderCreatedAt = null, ?string $paidAt = null): ShoppingOrder { $country = Country::firstOrCreate(['code' => 'DE'], [ 'phone' => '49', @@ -64,7 +70,7 @@ function repairTestCreateStarterKitOrder(User $registrant, User $shopOwner, ?str $userShop = UserShop::create([ 'user_id' => $shopOwner->id, - 'name' => 'T'.substr(uniqid('', true), 0, 6), + 'name' => 'T'.bin2hex(random_bytes(4)), 'slug' => 't-'.uniqid(), 'active' => true, ]); @@ -112,6 +118,10 @@ function repairTestCreateStarterKitOrder(User $registrant, User $shopOwner, ?str $order->save(); } + if ($registrant->fresh()->partner_paid_at === null) { + $registrant->forceFill(['partner_paid_at' => Carbon::parse($paidAt ?? $orderCreatedAt ?? now())])->save(); + } + return $order->fresh(['shopping_order_items.product']); } @@ -127,7 +137,7 @@ function repairTestCreateIncentive(): Incentive ]); } -it('syncMissingTrackingPartners zaehlt Bestandskunden, die im Zeitraum zum Berater upgraden (Bestelldatum zaehlt)', function () { +it('syncMissingTrackingPartners zaehlt Bestandskunden, die im Zeitraum zum Berater upgraden (Zahlungsdatum zaehlt)', function () { $incentive = repairTestCreateIncentive(); $sponsor = repairTestCreateUser(); $participant = IncentiveParticipant::factory()->create([ @@ -425,3 +435,34 @@ it('removeStaleSponsorTrackings laesst korrekte Trackings unangetastet', functio expect($removed)->toBe(0); expect(IncentiveNewPartner::where('participant_id', $participant->id)->exists())->toBeTrue(); }); + +it('zaehlt Neupartner im Monat der Zahlung, nicht der Bestellung (Kundenentscheid 16.09.2026)', function () { + Carbon::setTestNow(Carbon::parse('2026-04-01 06:49:35')); + + $incentive = repairTestCreateIncentive(); + $sponsor = repairTestCreateUser(); + $participant = IncentiveParticipant::factory()->create([ + 'incentive_id' => $incentive->id, + 'user_id' => $sponsor->id, + ]); + + // Bestellt vor dem Start, bezahlt am ersten Tag: zaehlt. + $paidOnStart = repairTestCreateUser($sponsor->id); + $order = repairTestCreateStarterKitOrder($paidOnStart, $sponsor, '2026-03-30 18:00:00', '2026-04-01 06:49:35'); + IncentiveTracker::trackNewPartner($order); + + // Bestellt am letzten Tag, bezahlt erst danach: zaehlt nicht. + $paidAfterEnd = repairTestCreateUser($sponsor->id); + repairTestCreateStarterKitOrder($paidAfterEnd, $sponsor, '2026-07-31 20:00:00', '2026-08-01 09:00:00'); + + Carbon::setTestNow(Carbon::parse('2026-08-02 05:00:00')); + app(IncentivePointsLogRepairService::class)->syncMissingTrackingPartners($participant); + + $tracked = IncentiveNewPartner::where('participant_id', $participant->id)->get(); + expect($tracked->pluck('user_id')->all())->toBe([$paidOnStart->id]) + ->and($tracked->first()->registered_at->toDateTimeString())->toBe('2026-04-01 06:49:35'); + + // Kompletter Neuaufbau kommt zum selben Ergebnis. + $participant->fresh()->rebuildFromSourceTables()->save(); + expect(IncentiveNewPartner::where('participant_id', $participant->id)->pluck('user_id')->all())->toBe([$paidOnStart->id]); +}); diff --git a/tests/Feature/Incentive/IncentiveRankingTest.php b/tests/Feature/Incentive/IncentiveRankingTest.php index 8ea69241..a308150c 100644 --- a/tests/Feature/Incentive/IncentiveRankingTest.php +++ b/tests/Feature/Incentive/IncentiveRankingTest.php @@ -1,6 +1,7 @@ toBe([1, 2, 3, 4]); }); + +describe('Gleichstand: wer den Stand zuerst erreicht hat (Recruiting-Challenge, Punkt 6)', function () use ($makeIncentiveRankingUser) { + $challengeWithTie = function () use ($makeIncentiveRankingUser): array { + $incentive = Incentive::factory()->create([ + 'points_partner_onetime' => 1, + 'points_abo_onetime' => 0, + 'min_direct_partners' => 0, + 'min_customer_abos' => 0, + ]); + + $participant = function (array $registrations) use ($incentive, $makeIncentiveRankingUser): IncentiveParticipant { + $participant = IncentiveParticipant::factory()->qualified()->create([ + 'incentive_id' => $incentive->id, + 'user_id' => $makeIncentiveRankingUser()->id, + 'accepted_terms_at' => now(), + 'total_points' => count($registrations), + ]); + foreach ($registrations as $registeredAt) { + IncentiveNewPartner::create([ + 'participant_id' => $participant->id, + 'user_id' => $makeIncentiveRankingUser()->id, + 'registered_at' => $registeredAt, + ]); + } + + return $participant; + }; + + return [$incentive, $participant]; + }; + + it('setzt bei gleicher Anzahl den vor, dessen letzter Neupartner frueher kam', function () use ($challengeWithTie) { + [$incentive, $participant] = $challengeWithTie(); + + // Beide haben 2 Neupartner; der spaeter angelegte hat die Anzahl frueher erreicht. + $reachedLater = $participant(['2026-10-02 10:00:00', '2026-11-20 09:00:00']); + $reachedEarlier = $participant(['2026-10-05 10:00:00', '2026-10-30 18:00:00']); + + IncentiveTracker::updateRanking($incentive); + + expect($reachedEarlier->refresh()->rank)->toBe(1) + ->and($reachedLater->refresh()->rank)->toBe(2); + }); + + it('laesst die Anzahl vor dem Zeitpunkt entscheiden', function () use ($challengeWithTie) { + [$incentive, $participant] = $challengeWithTie(); + + $fewerButEarly = $participant(['2026-10-01 08:00:00']); + $moreButLate = $participant(['2026-12-01 08:00:00', '2027-01-30 08:00:00']); + + IncentiveTracker::updateRanking($incentive); + + expect($moreButLate->refresh()->rank)->toBe(1) + ->and($fewerButEarly->refresh()->rank)->toBe(2); + }); + + it('haelt bestaetigte Teilnahme weiterhin vor dem Zeitpunkt', function () use ($challengeWithTie) { + [$incentive, $participant] = $challengeWithTie(); + + $anonymousEarly = $participant(['2026-10-01 08:00:00']); + $anonymousEarly->update(['accepted_terms_at' => null]); + $confirmedLate = $participant(['2026-12-01 08:00:00']); + + IncentiveTracker::updateRanking($incentive); + + expect($confirmedLate->refresh()->rank)->toBe(1) + ->and($anonymousEarly->refresh()->rank)->toBe(2); + }); + + it('vergibt bei vollstaendigem Gleichstand stabil nach Teilnehmer-ID', function () use ($challengeWithTie) { + [$incentive, $participant] = $challengeWithTie(); + + $first = $participant(['2026-10-10 10:00:00']); + $second = $participant(['2026-10-10 10:00:00']); + + IncentiveTracker::updateRanking($incentive); + + expect($first->refresh()->rank)->toBe(1) + ->and($second->refresh()->rank)->toBe(2); + }); +});