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

@ -0,0 +1,137 @@
<?php
namespace App\Services\Incentive;
use App\Models\Incentive;
use App\Models\IncentiveParticipant;
use Illuminate\Support\Collection;
/**
* Geldpraemie eines Incentives (Recruiting-Challenge, Entwicklungsplan E1).
*
* Praemie = Anzahl Neupartner mit Starterpaket x Betrag je Neupartner, dazu der
* Rang-Bonus, wenn der Teilnehmer qualifiziert ist und innerhalb der Gewinnerplaetze
* liegt. Grundlage ist der gespeicherte Stand des Teilnehmers (qualified_partners, rank),
* also derselbe Stand wie in der Rangliste.
*/
class IncentivePayoutService
{
/**
* @return array{partners: int, per_partner: float, partner_amount: float, rank_bonus: float, total: float}
*/
public function forParticipant(Incentive $incentive, IncentiveParticipant $participant): array
{
$partners = (int) $participant->qualified_partners;
$perPartner = (float) $incentive->amount_per_partner;
$partnerAmount = round($partners * $perPartner, 2);
$rankBonus = $participant->is_qualified ? $incentive->rankBonus($participant->rank) : 0.0;
return [
'partners' => $partners,
'per_partner' => $perPartner,
'partner_amount' => $partnerAmount,
'rank_bonus' => $rankBonus,
'total' => round($partnerAmount + $rankBonus, 2),
];
}
/**
* Auszahlungszeilen: alle Teilnehmer mit Praemie > 0, nach Rang.
*
* @return Collection<int, array{participant: IncentiveParticipant, payout: array{partners: int, per_partner: float, partner_amount: float, rank_bonus: float, total: float}}>
*/
public function rows(Incentive $incentive): Collection
{
return IncentiveParticipant::query()
->where('incentive_id', $incentive->id)
->with('user', 'user.account')
->orderByRankNullsLast()
->orderByDesc('qualified_partners')
->get()
->map(fn (IncentiveParticipant $participant) => [
'participant' => $participant,
'payout' => $this->forParticipant($incentive, $participant),
])
->filter(fn (array $row) => $row['payout']['total'] > 0)
->values();
}
/**
* Summen ueber alle Auszahlungszeilen.
*
* @param Collection<int, array{participant: IncentiveParticipant, payout: array{partners: int, per_partner: float, partner_amount: float, rank_bonus: float, total: float}}> $rows
* @return array{recipients: int, partners: int, partner_amount: float, rank_bonus: float, total: float}
*/
public function totals(Collection $rows): array
{
return [
'recipients' => $rows->count(),
'partners' => (int) $rows->sum(fn (array $row) => $row['payout']['partners']),
'partner_amount' => round((float) $rows->sum(fn (array $row) => $row['payout']['partner_amount']), 2),
'rank_bonus' => round((float) $rows->sum(fn (array $row) => $row['payout']['rank_bonus']), 2),
'total' => round((float) $rows->sum(fn (array $row) => $row['payout']['total']), 2),
];
}
/**
* Spaltenueberschriften des XLSX-Exports.
*
* @return list<string>
*/
public function exportHeaders(): array
{
return [
__('incentive.rank'),
__('incentive.export_user_id'),
__('incentive.consultant'),
'E-Mail',
__('incentive.admin_terms_accepted'),
__('incentive.partners'),
__('incentive.amount_per_partner'),
__('incentive.payout_partner_amount'),
__('incentive.payout_rank_bonus'),
__('incentive.payout_total'),
__('incentive.total_points'),
__('incentive.qualified'),
];
}
/**
* Zeilen des XLSX-Exports (Betraege als Zahlen, damit Excel summieren kann).
*
* @return list<array<int, string|int|float>>
*/
public function exportRows(Incentive $incentive): array
{
return $this->rows($incentive)->map(function (array $row) {
$participant = $row['participant'];
$payout = $row['payout'];
$user = $participant->user;
$name = $user?->account
? trim($user->account->first_name.' '.$user->account->last_name)
: ($user?->getFullName() ?: '');
return [
$participant->rank ?? '',
(int) $participant->user_id,
$name,
(string) ($user?->email ?? ''),
$participant->accepted_terms_at
? $participant->accepted_terms_at->format('d.m.Y H:i')
: __('incentive.admin_terms_pending'),
$payout['partners'],
$payout['per_partner'],
$payout['partner_amount'],
$payout['rank_bonus'],
$payout['total'],
(int) $participant->total_points,
$participant->is_qualified ? __('incentive.yes') : __('incentive.no'),
];
})->all();
}
public static function formatAmount(float $amount): string
{
return number_format($amount, 2, ',', '.').' €';
}
}

View file

@ -350,7 +350,7 @@ class IncentivePointsLogRepairService
public function syncMissingSalesVolumeLogs(IncentiveParticipant $participant): int
{
$incentive = $participant->incentive;
if (! $incentive) {
if (! $incentive || ! $incentive->includesSalesPoints()) {
return 0;
}

View file

@ -136,7 +136,8 @@ class IncentiveTracker
/**
* Track accumulated sales volume points.
* Punkte werden NUR gezaehlt wenn der Umsatz von einem gettrackten
* Neupartner oder Neuabo stammt.
* Neupartner oder Neuabo stammt. Incentives mit `include_sales_points = 0`
* (Recruiting-Challenge) bekommen keine Umsatzpunkte.
*/
public static function trackSalesVolume(UserSalesVolume $user_sales_volume): void
{
@ -155,7 +156,7 @@ class IncentiveTracker
// A. Pruefen ob der User ein gettrackter Neupartner ist
$partner_trackings = IncentiveNewPartner::where('user_id', $user_sales_volume->user_id)
->whereHas('participant.incentive', fn ($q) => $q->active())
->whereHas('participant.incentive', fn ($q) => $q->active()->includingSalesPoints())
->with('participant.incentive')
->get();
@ -210,7 +211,7 @@ class IncentiveTracker
$abo_trackings = $userAboFromOrder
? IncentiveNewAbo::query()
->where('user_abo_id', $userAboFromOrder->id)
->whereHas('participant.incentive', fn ($q) => $q->active())
->whereHas('participant.incentive', fn ($q) => $q->active()->includingSalesPoints())
->with('participant.incentive')
->get()
: collect();