mivita/app/Models/IncentiveParticipant.php
2026-04-10 17:15:27 +02:00

536 lines
20 KiB
PHP

<?php
namespace App\Models;
use App\User;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
* Class IncentiveParticipant
*
* @property int $id
* @property int $incentive_id
* @property int $user_id
* @property Carbon|null $accepted_terms_at
* @property int $total_points
* @property int $qualified_partners
* @property int $qualified_abos
* @property bool $is_qualified
* @property int|null $rank
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property-read Incentive $incentive
* @property-read User $user
* @property-read Collection<int, IncentivePointsLog> $pointsLog
* @property-read Collection<int, IncentiveNewPartner> $newPartners
* @property-read Collection<int, IncentiveNewAbo> $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';
}
}