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>
503 lines
19 KiB
PHP
503 lines
19 KiB
PHP
<?php
|
|
|
|
namespace App\Services\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\UserInvoice;
|
|
use App\Models\UserSalesVolume;
|
|
use App\User;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class IncentivePointsLogRepairService
|
|
{
|
|
/**
|
|
* Fehlende Neupartner-Tracking-Zeilen anlegen (Starterpaket / gleiche Regeln wie Neuaufbau A).
|
|
* Nutzt IncentiveTracker::trackNewPartner, wenn eine qualifizierte Bestellung existiert.
|
|
*
|
|
* Neupartner = Starterpaket im Qualifikationszeitraum bezahlt (Bestelldatum, wie Live-Tracking).
|
|
* Das User-Registrierungsdatum ist bewusst irrelevant: auch Bestandskunden, die im Zeitraum
|
|
* zum Berater upgraden, zaehlen.
|
|
*
|
|
* @return int Anzahl nachgezogener Partner-Trackings fuer diesen Teilnehmer
|
|
*/
|
|
public function syncMissingTrackingPartners(IncentiveParticipant $participant): int
|
|
{
|
|
$incentive = $participant->incentive;
|
|
if (! $incentive) {
|
|
return 0;
|
|
}
|
|
|
|
$added = 0;
|
|
|
|
[$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)
|
|
->whereBetween('partner_paid_at', [$windowStart, $windowEnd])
|
|
->whereHas('shopping_orders', function ($q) {
|
|
$q->wherePaidRegistrationIncludesStarterKit();
|
|
})
|
|
->get();
|
|
|
|
$firstStarterOrders = $candidates->isNotEmpty()
|
|
? ShoppingOrder::query()
|
|
->whereIn('auth_user_id', $candidates->pluck('id'))
|
|
->wherePaidRegistrationIncludesStarterKit()
|
|
->orderBy('id')
|
|
->get()
|
|
->groupBy('auth_user_id')
|
|
->map(fn ($orders) => $orders->first())
|
|
: collect();
|
|
|
|
foreach ($candidates as $partner) {
|
|
if (IncentiveNewPartner::query()
|
|
->where('participant_id', $participant->id)
|
|
->where('user_id', $partner->id)
|
|
->exists()) {
|
|
continue;
|
|
}
|
|
|
|
$order = $firstStarterOrders->get($partner->id);
|
|
|
|
if ($order) {
|
|
IncentiveTracker::trackNewPartner($order);
|
|
}
|
|
|
|
if (! IncentiveNewPartner::query()
|
|
->where('participant_id', $participant->id)
|
|
->where('user_id', $partner->id)
|
|
->exists()) {
|
|
$registeredAt = IncentiveTracker::newPartnerCountedAt($partner);
|
|
|
|
$newPartner = IncentiveNewPartner::create([
|
|
'participant_id' => $participant->id,
|
|
'user_id' => $partner->id,
|
|
'registered_at' => $registeredAt,
|
|
]);
|
|
|
|
IncentivePointsLog::create([
|
|
'participant_id' => $participant->id,
|
|
'type' => 'partner',
|
|
'source_type' => User::class,
|
|
'source_id' => $partner->id,
|
|
'source_label' => $partner->getFullName() ?: $partner->email ?: ('User #'.$partner->id),
|
|
'month' => $registeredAt->month,
|
|
'year' => $registeredAt->year,
|
|
'points_onetime' => $incentive->points_partner_onetime,
|
|
'points_accumulated' => 0,
|
|
'incentive_new_partner_id' => $newPartner->id,
|
|
]);
|
|
}
|
|
|
|
if (IncentiveNewPartner::query()
|
|
->where('participant_id', $participant->id)
|
|
->where('user_id', $partner->id)
|
|
->exists()) {
|
|
$added++;
|
|
}
|
|
}
|
|
|
|
return $added;
|
|
}
|
|
|
|
/**
|
|
* Storno (Kundenentscheid 25.09.2026): Ist jede bezahlte Starterpaket-Bestellung eines
|
|
* getrackten Neupartners storniert, zaehlt er nicht mehr. Die Tracking-Zeile bleibt mit
|
|
* cancelled_at stehen (Anzeige in "Meine Berechnung"), eine Storno-Logzeile nimmt die
|
|
* Einmalpunkte zurueck.
|
|
*
|
|
* @return int Anzahl neu als storniert markierter Neupartner
|
|
*/
|
|
public function markCancelledPartners(IncentiveParticipant $participant): int
|
|
{
|
|
$incentive = $participant->incentive;
|
|
if (! $incentive) {
|
|
return 0;
|
|
}
|
|
|
|
$marked = 0;
|
|
|
|
foreach ($participant->newPartners()->counting()->with('user')->get() as $tracking) {
|
|
$orders = ShoppingOrder::query()
|
|
->where('auth_user_id', $tracking->user_id)
|
|
->wherePaidRegistrationIncludesStarterKit()
|
|
->get(['id', 'txaction', 'updated_at']);
|
|
|
|
if ($orders->isEmpty() || $orders->contains(fn (ShoppingOrder $order) => $order->txaction !== 'cancelled')) {
|
|
continue;
|
|
}
|
|
|
|
$cancelledAt = UserInvoice::query()
|
|
->whereIn('shopping_order_id', $orders->pluck('id'))
|
|
->where('cancellation', true)
|
|
->whereNull('cancellation_id')
|
|
->min('created_at');
|
|
$cancelledAt = Carbon::parse($cancelledAt ?? $orders->max('updated_at') ?? now());
|
|
|
|
DB::transaction(function () use ($participant, $incentive, $tracking, $cancelledAt) {
|
|
$tracking->update(['cancelled_at' => $cancelledAt]);
|
|
|
|
IncentivePointsLog::create([
|
|
'participant_id' => $participant->id,
|
|
'type' => 'partner',
|
|
'source_type' => User::class,
|
|
'source_id' => $tracking->user_id,
|
|
'source_label' => 'Storno: '.($tracking->user?->getFullName() ?: 'User #'.$tracking->user_id),
|
|
'month' => $cancelledAt->month,
|
|
'year' => $cancelledAt->year,
|
|
'points_onetime' => -$incentive->points_partner_onetime,
|
|
'points_accumulated' => 0,
|
|
'is_storno' => true,
|
|
'incentive_new_partner_id' => $tracking->id,
|
|
]);
|
|
});
|
|
|
|
$marked++;
|
|
}
|
|
|
|
return $marked;
|
|
}
|
|
|
|
/**
|
|
* Entfernt Neupartner-Trackings, deren Partner inzwischen einen anderen Sponsor hat
|
|
* (z. B. nach Umhaengen in der Teamstruktur). Die zugehoerigen Log-Zeilen (Einmalpunkte
|
|
* und akkumulierte Umsatzpunkte) werden mitgeloescht; beim aktuellen Sponsor legt
|
|
* {@see syncMissingTrackingPartners} + {@see syncMissingSalesVolumeLogs} im selben
|
|
* Lauf alles neu an. Geloeschte User bleiben unangetastet.
|
|
*
|
|
* Ein leerer Sponsor (m_sponsor = NULL, z. B. Admin-Umbau in Arbeit oder verwaister
|
|
* Sponsor entfernt) gilt NICHT als Sponsorwechsel: das Tracking bleibt erhalten,
|
|
* geloescht wird nur bei einem tatsaechlich gesetzten, abweichenden Sponsor.
|
|
*
|
|
* @return int Anzahl entfernter Partner-Trackings fuer diesen Teilnehmer
|
|
*/
|
|
public function removeStaleSponsorTrackings(IncentiveParticipant $participant): int
|
|
{
|
|
$removed = 0;
|
|
|
|
$trackings = $participant->newPartners()->get();
|
|
if ($trackings->isEmpty()) {
|
|
return 0;
|
|
}
|
|
|
|
$sponsorIdByUserId = User::query()
|
|
->whereIn('id', $trackings->pluck('user_id'))
|
|
->pluck('m_sponsor', 'id');
|
|
|
|
foreach ($trackings as $tracking) {
|
|
if (! $sponsorIdByUserId->has($tracking->user_id)) {
|
|
continue;
|
|
}
|
|
|
|
$currentSponsorId = $sponsorIdByUserId->get($tracking->user_id);
|
|
|
|
if ($currentSponsorId === null) {
|
|
Log::info('Incentive-Repair: Neupartner-Tracking uebersprungen, m_sponsor ist leer (kein Sponsorwechsel)', [
|
|
'participant_id' => $participant->id,
|
|
'tracking_id' => $tracking->id,
|
|
'partner_user_id' => $tracking->user_id,
|
|
'tracked_sponsor_user_id' => $participant->user_id,
|
|
]);
|
|
|
|
continue;
|
|
}
|
|
|
|
if ((int) $currentSponsorId === (int) $participant->user_id) {
|
|
continue;
|
|
}
|
|
|
|
Log::info('Incentive-Repair: Neupartner-Tracking wegen Sponsorwechsel entfernt', [
|
|
'participant_id' => $participant->id,
|
|
'tracking_id' => $tracking->id,
|
|
'partner_user_id' => $tracking->user_id,
|
|
'old_sponsor_user_id' => $participant->user_id,
|
|
'new_sponsor_user_id' => (int) $currentSponsorId,
|
|
]);
|
|
|
|
IncentivePointsLog::where('incentive_new_partner_id', $tracking->id)->delete();
|
|
$tracking->delete();
|
|
$removed++;
|
|
}
|
|
|
|
return $removed;
|
|
}
|
|
|
|
/**
|
|
* Incentive ohne Abos: bereits erfasste Abo-Trackings samt Log-Zeilen entfernen
|
|
* (z. B. nachdem der Schalter im Admin ausgeschaltet wurde).
|
|
*
|
|
* @return int Anzahl entfernter Abo-Trackings
|
|
*/
|
|
public function removeExcludedAboTrackings(IncentiveParticipant $participant): int
|
|
{
|
|
if (! $participant->incentive || $participant->incentive->includesAbos()) {
|
|
return 0;
|
|
}
|
|
|
|
$participant->pointsLog()->where('type', 'abo')->delete();
|
|
|
|
return $participant->newAbos()->delete();
|
|
}
|
|
|
|
/**
|
|
* Fehlende Abo-Tracking-Zeilen anlegen (Kundenabo ot / Eigenabo me, wie IncentiveTracker).
|
|
* Zuerst trackAboActivated ueber Erstbestellung; ohne UserAboOrder manuell wie Neuaufbau B.
|
|
*
|
|
* @return int Anzahl nachgezogener Abo-Trackings fuer diesen Teilnehmer
|
|
*/
|
|
public function syncMissingTrackingAbos(IncentiveParticipant $participant): int
|
|
{
|
|
$incentive = $participant->incentive;
|
|
if (! $incentive || ! $incentive->includesAbos()) {
|
|
return 0;
|
|
}
|
|
|
|
$added = 0;
|
|
|
|
[$qualStart, $qualEnd] = $incentive->qualificationWindow();
|
|
|
|
$candidatesOt = UserAbo::query()
|
|
->where('is_for', 'ot')
|
|
->where('status', 2)
|
|
->where('member_id', $participant->user_id)
|
|
->whereBetween('created_at', [$qualStart, $qualEnd])
|
|
->get();
|
|
|
|
$candidatesMe = UserAbo::query()
|
|
->where('is_for', 'me')
|
|
->where('status', 2)
|
|
->where('user_id', $participant->user_id)
|
|
->where(function ($q) use ($qualStart, $qualEnd) {
|
|
$q->whereBetween('created_at', [$qualStart, $qualEnd])
|
|
->orWhere('created_at', '<', $qualStart);
|
|
})
|
|
->get();
|
|
|
|
foreach ($candidatesOt->concat($candidatesMe) as $userAbo) {
|
|
if (IncentiveNewAbo::query()
|
|
->where('participant_id', $participant->id)
|
|
->where('user_abo_id', $userAbo->id)
|
|
->exists()) {
|
|
continue;
|
|
}
|
|
|
|
$order = UserAboOrder::query()
|
|
->where('user_abo_id', $userAbo->id)
|
|
->orderBy('id')
|
|
->with('shopping_order')
|
|
->first();
|
|
|
|
$shoppingOrder = $order?->shopping_order;
|
|
|
|
if ($shoppingOrder) {
|
|
IncentiveTracker::trackAboActivated($shoppingOrder);
|
|
}
|
|
|
|
if (! IncentiveNewAbo::query()
|
|
->where('participant_id', $participant->id)
|
|
->where('user_abo_id', $userAbo->id)
|
|
->exists()) {
|
|
$activatedAt = $userAbo->created_at;
|
|
$logMonth = (int) $userAbo->created_at->month;
|
|
$logYear = (int) $userAbo->created_at->year;
|
|
|
|
if ($userAbo->is_for === 'me' && $userAbo->created_at->lt($qualStart)) {
|
|
$activatedAt = $qualStart->copy();
|
|
$logMonth = (int) $qualStart->month;
|
|
$logYear = (int) $qualStart->year;
|
|
}
|
|
|
|
$newAbo = IncentiveNewAbo::create([
|
|
'participant_id' => $participant->id,
|
|
'user_abo_id' => $userAbo->id,
|
|
'activated_at' => $activatedAt,
|
|
]);
|
|
|
|
IncentivePointsLog::create([
|
|
'participant_id' => $participant->id,
|
|
'type' => 'abo',
|
|
'source_type' => UserAbo::class,
|
|
'source_id' => $userAbo->id,
|
|
'source_label' => $userAbo->email ?: ('Abo #'.$userAbo->id),
|
|
'month' => $logMonth,
|
|
'year' => $logYear,
|
|
'points_onetime' => $incentive->points_abo_onetime,
|
|
'points_accumulated' => 0,
|
|
'incentive_new_abo_id' => $newAbo->id,
|
|
]);
|
|
}
|
|
|
|
if (IncentiveNewAbo::query()
|
|
->where('participant_id', $participant->id)
|
|
->where('user_abo_id', $userAbo->id)
|
|
->exists()) {
|
|
$added++;
|
|
}
|
|
}
|
|
|
|
return $added;
|
|
}
|
|
|
|
/**
|
|
* Setzt fehlende incentive_new_partner_id / incentive_new_abo_id an bestehenden Log-Zeilen.
|
|
*
|
|
* @return array{partner_fk: int, abo_fk: int, onetime_partner_fk: int, onetime_abo_fk: int}
|
|
*/
|
|
public function repairForeignKeys(IncentiveParticipant $participant): array
|
|
{
|
|
$stats = [
|
|
'partner_fk' => 0,
|
|
'abo_fk' => 0,
|
|
'onetime_partner_fk' => 0,
|
|
'onetime_abo_fk' => 0,
|
|
];
|
|
|
|
$newPartnerByUserId = $participant->newPartners()->get()->keyBy('user_id');
|
|
$newAboByUserAboId = $participant->newAbos()->get()->keyBy('user_abo_id');
|
|
|
|
foreach ($participant->pointsLog()->where('type', 'partner')->whereNull('incentive_new_partner_id')->cursor() as $log) {
|
|
if ($log->source_type === User::class && $log->source_id) {
|
|
$np = $newPartnerByUserId->get($log->source_id);
|
|
if ($np) {
|
|
$log->update(['incentive_new_partner_id' => $np->id]);
|
|
$stats['onetime_partner_fk']++;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($log->user_sales_volume_id) {
|
|
$sv = UserSalesVolume::find($log->user_sales_volume_id);
|
|
if ($sv && $sv->user_id) {
|
|
$np = $newPartnerByUserId->get($sv->user_id);
|
|
if ($np) {
|
|
$log->update(['incentive_new_partner_id' => $np->id]);
|
|
$stats['partner_fk']++;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$orderIdToUserAboId = [];
|
|
foreach ($participant->pointsLog()->where('type', 'abo')->whereNull('incentive_new_abo_id')->cursor() as $log) {
|
|
if ($log->source_type === UserAbo::class && $log->source_id) {
|
|
$na = $newAboByUserAboId->get($log->source_id);
|
|
if ($na) {
|
|
$log->update(['incentive_new_abo_id' => $na->id]);
|
|
$stats['onetime_abo_fk']++;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($log->user_sales_volume_id) {
|
|
$sv = UserSalesVolume::find($log->user_sales_volume_id);
|
|
if ($sv && $sv->shopping_order_id) {
|
|
if (! isset($orderIdToUserAboId[$sv->shopping_order_id])) {
|
|
$orderIdToUserAboId[$sv->shopping_order_id] = UserAboOrder::where('shopping_order_id', $sv->shopping_order_id)->value('user_abo_id');
|
|
}
|
|
$userAboId = $orderIdToUserAboId[$sv->shopping_order_id] ?? null;
|
|
if ($userAboId) {
|
|
$na = $newAboByUserAboId->get($userAboId);
|
|
if ($na) {
|
|
$log->update(['incentive_new_abo_id' => $na->id]);
|
|
$stats['abo_fk']++;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return $stats;
|
|
}
|
|
|
|
/**
|
|
* Ruft IncentiveTracker::trackSalesVolume fuer Kandidaten-USVs auf, bei denen noch kein Log-Eintrag fuer diesen Teilnehmer existiert.
|
|
*
|
|
* @return int Anzahl neu angelegter Log-Zeilen (geschaetzt ueber Vorher/Nachher pro Teilnehmer)
|
|
*/
|
|
public function syncMissingSalesVolumeLogs(IncentiveParticipant $participant): int
|
|
{
|
|
$incentive = $participant->incentive;
|
|
if (! $incentive || ! $incentive->includesSalesPoints()) {
|
|
return 0;
|
|
}
|
|
|
|
$synced = 0;
|
|
$months = $incentive->getCalculationMonths();
|
|
|
|
$newPartnerUserIds = $participant->newPartners()->pluck('user_id')->filter()->values();
|
|
$trackedUserAboIds = $participant->newAbos()->pluck('user_abo_id')->filter()->values();
|
|
|
|
$orderIdsForAbos = $trackedUserAboIds->isNotEmpty()
|
|
? UserAboOrder::query()->whereIn('user_abo_id', $trackedUserAboIds)->pluck('shopping_order_id')->unique()->values()
|
|
: collect();
|
|
|
|
foreach ($months as $period) {
|
|
if ($newPartnerUserIds->isNotEmpty()) {
|
|
$svs = UserSalesVolume::query()
|
|
->whereIn('user_id', $newPartnerUserIds)
|
|
->where('month', $period['month'])
|
|
->where('year', $period['year'])
|
|
->where('status', '!=', 6)
|
|
->get();
|
|
|
|
foreach ($svs as $sv) {
|
|
if ((int) abs($sv->points ?? 0) <= 0) {
|
|
continue;
|
|
}
|
|
if ($this->participantHasSalesVolumeLog($participant, $sv->id)) {
|
|
continue;
|
|
}
|
|
IncentiveTracker::trackSalesVolume($sv);
|
|
if ($this->participantHasSalesVolumeLog($participant, $sv->id)) {
|
|
$synced++;
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($orderIdsForAbos->isNotEmpty()) {
|
|
$svs = UserSalesVolume::query()
|
|
->whereIn('shopping_order_id', $orderIdsForAbos)
|
|
->where('month', $period['month'])
|
|
->where('year', $period['year'])
|
|
->where('status', '!=', 6)
|
|
->get();
|
|
|
|
foreach ($svs as $sv) {
|
|
if ((int) abs($sv->points ?? 0) <= 0) {
|
|
continue;
|
|
}
|
|
if ($this->participantHasSalesVolumeLog($participant, $sv->id)) {
|
|
continue;
|
|
}
|
|
IncentiveTracker::trackSalesVolume($sv);
|
|
if ($this->participantHasSalesVolumeLog($participant, $sv->id)) {
|
|
$synced++;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return $synced;
|
|
}
|
|
|
|
private function participantHasSalesVolumeLog(IncentiveParticipant $participant, int $userSalesVolumeId): bool
|
|
{
|
|
return IncentivePointsLog::query()
|
|
->where('participant_id', $participant->id)
|
|
->where('user_sales_volume_id', $userSalesVolumeId)
|
|
->where('is_storno', false)
|
|
->exists();
|
|
}
|
|
}
|