Incentive (E1): Recruiting-Challenge mit Geldpraemie, Auszahlungs-Export und parallelen Incentives

Neue Felder amount_per_partner, bonus_rank_1..3 und include_sales_points an
incentives. Mit ausgeschalteten Umsatzpunkten zaehlt die Wertung nur die
Einmalpunkte je Neupartner, die Rangliste entspricht damit der Anzahl
Neupartner mit Starterpaket. IncentivePayoutService rechnet Neupartner mal
Betrag plus Rang-Bonus fuer qualifizierte Gewinner; Admin-Detailseite bekommt
Praemienspalte, Summen und XLSX-Export fuer die Auszahlung.

Sidenav und Dashboard zeigen alle aktiven Incentives, damit Montenegro und
die Challenge parallel laufen koennen. Teaser, Rangliste und Details zeigen
bei Geldpraemie die Praemien-Kachel und "Deine Praemie bisher" und blenden
Abo-Bloecke aus, wenn Abos keine Rolle spielen. Montenegro bleibt unveraendert.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-09-18 12:10:27 +00:00
parent 03416b0afe
commit e3efa2f5f0
27 changed files with 1418 additions and 129 deletions

View file

@ -31,6 +31,11 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property int $min_direct_partners
* @property int $min_customer_abos
* @property int $max_winners
* @property float|null $amount_per_partner
* @property float|null $bonus_rank_1
* @property float|null $bonus_rank_2
* @property float|null $bonus_rank_3
* @property bool $include_sales_points
* @property int $status
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
@ -59,6 +64,11 @@ class Incentive extends Model
'min_direct_partners' => 'int',
'min_customer_abos' => 'int',
'max_winners' => 'int',
'amount_per_partner' => 'float',
'bonus_rank_1' => 'float',
'bonus_rank_2' => 'float',
'bonus_rank_3' => 'float',
'include_sales_points' => 'bool',
'status' => 'int',
'qualification_start' => 'date',
'qualification_end' => 'date',
@ -83,9 +93,19 @@ class Incentive extends Model
'min_direct_partners',
'min_customer_abos',
'max_winners',
'amount_per_partner',
'bonus_rank_1',
'bonus_rank_2',
'bonus_rank_3',
'include_sales_points',
'status',
];
/**
* Anzahl der Plaetze, fuer die ein Rang-Bonus hinterlegt werden kann.
*/
public const RANK_BONUS_SLOTS = 3;
public static $statusTypes = [
0 => 'draft',
1 => 'active',
@ -121,6 +141,14 @@ class Incentive extends Model
return $query->where('status', 1);
}
/**
* Incentives, bei denen Umsatzpunkte der Neupartner/Neuabos mitzaehlen.
*/
public function scopeIncludingSalesPoints($query)
{
return $query->where('include_sales_points', true);
}
public function scopeInQualificationPeriod($query, ?Carbon $date = null)
{
$date = $date ?: Carbon::now();
@ -149,6 +177,20 @@ class Incentive extends Model
// Helpers
/**
* Aktive Incentives fuer Sidenav und Dashboard: mehrere koennen parallel laufen
* (z. B. Montenegro bis Oktober, Recruiting-Challenge ab Oktober), aelteste zuerst.
*
* @return Collection<int, Incentive>
*/
public static function activeForNavigation(): Collection
{
return static::active()
->orderBy('qualification_start')
->orderBy('id')
->get();
}
public function isActive(): bool
{
return $this->status === 1;
@ -198,6 +240,76 @@ class Incentive extends Model
return self::$statusColors[$this->status] ?? 'default';
}
/**
* Wird eine Geldpraemie je Neupartner ausgezahlt (Recruiting-Challenge)?
*/
public function hasCashPayout(): bool
{
return (float) $this->amount_per_partner > 0 || $this->rankBonuses() !== [];
}
/**
* Rang-Boni je Platz (nur gesetzte Betraege > 0, Platz => Betrag).
*
* @return array<int, float>
*/
public function rankBonuses(): array
{
$bonuses = [];
for ($rank = 1; $rank <= self::RANK_BONUS_SLOTS; $rank++) {
$amount = (float) $this->{'bonus_rank_'.$rank};
if ($amount > 0) {
$bonuses[$rank] = $amount;
}
}
return $bonuses;
}
/**
* Rang-Bonus fuer einen Platz, 0 wenn keiner hinterlegt oder der Platz ausserhalb der Gewinnerplaetze liegt.
*/
public function rankBonus(?int $rank): float
{
if ($rank === null || $rank < 1 || $rank > $this->max_winners) {
return 0.0;
}
return $this->rankBonuses()[$rank] ?? 0.0;
}
/**
* Zaehlen Umsatzpunkte der Neupartner/Neuabos mit? Aus = Wertung nur nach Einmalpunkten.
*/
public function includesSalesPoints(): bool
{
return $this->include_sales_points === null || (bool) $this->include_sales_points;
}
/**
* Gibt es eine Mindestqualifikation (direkte Partner oder Kundenabos)?
*/
public function hasMinimumRequirements(): bool
{
return $this->min_direct_partners > 0 || $this->min_customer_abos > 0;
}
/**
* Spielen Abos in diesem Incentive eine Rolle (Punkte oder Mindestanzahl)?
*/
public function usesAbos(): bool
{
return $this->points_abo_onetime > 0 || $this->min_customer_abos > 0;
}
/**
* Rangliste nach Anzahl Neupartner: Einmalpunkte je Partner, keine Abo- und Umsatzpunkte.
*/
public function ranksByPartnerCount(): bool
{
return ! $this->includesSalesPoints() && ! $this->usesAbos() && $this->points_partner_onetime > 0;
}
/**
* Get specific translation for a field and locale.
*/

View file

@ -365,6 +365,11 @@ class IncentiveParticipant extends Model
}
// C. Akkumulierte Punkte NUR von Neupartnern und Neuabos
// (entfaellt bei Incentives ohne Umsatzpunkte, z. B. Recruiting-Challenge)
if (! $incentive->includesSalesPoints()) {
return $this->recalculateFromTrackingTables();
}
$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'))