536 lines
20 KiB
PHP
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';
|
|
}
|
|
}
|