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

@ -2,6 +2,7 @@
namespace App\Http\Controllers\Admin;
use App\Exports\xExport;
use App\Http\Controllers\Controller;
use App\Models\Incentive;
use App\Models\IncentiveNewAbo;
@ -12,8 +13,11 @@ use App\Models\UserAbo;
use App\Models\UserAboOrder;
use App\Models\UserSalesVolume;
use App\Services\Incentive\IncentiveCalculationService;
use App\Services\Incentive\IncentivePayoutService;
use App\User;
use Maatwebsite\Excel\Facades\Excel;
use Request;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class IncentiveController extends Controller
{
@ -50,8 +54,14 @@ class IncentiveController extends Controller
'min_direct_partners' => 'required|integer|min:0',
'min_customer_abos' => 'required|integer|min:0',
'max_winners' => 'required|integer|min:1',
'amount_per_partner' => 'nullable|numeric|min:0|max:999999',
'bonus_rank_1' => 'nullable|numeric|min:0|max:999999',
'bonus_rank_2' => 'nullable|numeric|min:0|max:999999',
'bonus_rank_3' => 'nullable|numeric|min:0|max:999999',
'include_sales_points' => 'nullable|boolean',
'status' => 'required|integer|in:0,1,2',
]);
$data = $this->normalizePayoutFields($data);
$data = array_merge($data, $this->extractTranslations());
@ -70,12 +80,52 @@ class IncentiveController extends Controller
->orderByIncentiveLeaderboard()
->get();
$payoutService = new IncentivePayoutService;
$payoutByParticipantId = $incentive->hasCashPayout()
? $participants->mapWithKeys(fn (IncentiveParticipant $participant) => [
$participant->id => $payoutService->forParticipant($incentive, $participant),
])
: collect();
return view('admin.incentive.show', [
'incentive' => $incentive,
'participants' => $participants,
'payoutByParticipantId' => $payoutByParticipantId,
'payoutTotals' => $incentive->hasCashPayout()
? $payoutService->totals($payoutService->rows($incentive))
: null,
]);
}
/**
* Auszahlungsliste der Geldpraemie als XLSX (Februar-Auszahlung der Recruiting-Challenge).
*/
public function payoutExport($id): BinaryFileResponse
{
$incentive = Incentive::findOrFail($id);
$service = new IncentivePayoutService;
$filename = 'incentive-'.$incentive->slug.'-auszahlung-'.now()->format('Y-m-d').'.xlsx';
return Excel::download(new xExport($service->exportRows($incentive), $service->exportHeaders()), $filename);
}
/**
* Leere Betragsfelder als NULL speichern, Checkbox als bool.
*
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
private function normalizePayoutFields(array $data): array
{
foreach (['amount_per_partner', 'bonus_rank_1', 'bonus_rank_2', 'bonus_rank_3'] as $field) {
$data[$field] = isset($data[$field]) && $data[$field] !== '' ? round((float) $data[$field], 2) : null;
}
$data['include_sales_points'] = filter_var($data['include_sales_points'] ?? false, FILTER_VALIDATE_BOOLEAN);
return $data;
}
public function edit($id)
{
$incentive = Incentive::findOrFail($id);
@ -102,8 +152,14 @@ class IncentiveController extends Controller
'min_direct_partners' => 'required|integer|min:0',
'min_customer_abos' => 'required|integer|min:0',
'max_winners' => 'required|integer|min:1',
'amount_per_partner' => 'nullable|numeric|min:0|max:999999',
'bonus_rank_1' => 'nullable|numeric|min:0|max:999999',
'bonus_rank_2' => 'nullable|numeric|min:0|max:999999',
'bonus_rank_3' => 'nullable|numeric|min:0|max:999999',
'include_sales_points' => 'nullable|boolean',
'status' => 'required|integer|in:0,1,2',
]);
$data = $this->normalizePayoutFields($data);
$data = array_merge($data, $this->extractTranslations());

View file

@ -57,18 +57,24 @@ class HomeController extends Controller
$user = Auth::user();
$activeIncentive = null;
$incentiveParticipant = null;
// Mehrere Incentives koennen parallel aktiv sein (E1): je eine Dashboard-Karte.
$activeIncentives = collect();
$incentiveParticipants = collect();
if ($user->isActiveAccount()) {
$activeIncentive = Incentive::active()->first();
if ($activeIncentive) {
$incentiveParticipant = IncentiveParticipant::where('incentive_id', $activeIncentive->id)
$activeIncentives = Incentive::activeForNavigation();
if ($activeIncentives->isNotEmpty()) {
$incentiveParticipants = IncentiveParticipant::query()
->whereIn('incentive_id', $activeIncentives->pluck('id'))
->where('user_id', $user->id)
->first();
->get()
->keyBy('incentive_id');
}
}
$activeIncentive = $activeIncentives->first();
$incentiveParticipant = $activeIncentive ? $incentiveParticipants->get($activeIncentive->id) : null;
// Eigenes Abo des Beraters für die Einmalprodukte-Dashboard-Card (nur wenn
// das Zeitfenster offen ist und das Feature für den User sichtbar ist).
$oneTimeAbo = null;
@ -96,6 +102,8 @@ class HomeController extends Controller
'dashboardNews' => DashboardNews::getActiveNews(),
'activeIncentive' => $activeIncentive,
'incentiveParticipant' => $incentiveParticipant,
'activeIncentives' => $activeIncentives,
'incentiveParticipants' => $incentiveParticipants,
'oneTimeAbo' => $oneTimeAbo,
'monthlyStats' => $monthlyStats,
];

View file

@ -7,6 +7,7 @@ use App\Http\Controllers\Controller;
use App\Models\Incentive;
use App\Models\IncentiveParticipant;
use App\Models\UserAbo;
use App\Services\Incentive\IncentivePayoutService;
use App\Services\Incentive\IncentivePointsLogRepairService;
use App\Services\Incentive\IncentiveTracker;
use App\User;
@ -68,6 +69,16 @@ class IncentiveController extends Controller
$participateHasTrackableAbos = $this->userHasTrackableAbosForIncentive($user, $incentive);
}
$payoutService = new IncentivePayoutService;
$participantPayout = $incentive->hasCashPayout() && $participant
? $payoutService->forParticipant($incentive, $participant)
: null;
$rankingPayouts = $incentive->hasCashPayout()
? $ranking->getCollection()->mapWithKeys(fn (IncentiveParticipant $row) => [
$row->id => $payoutService->forParticipant($incentive, $row),
])
: collect();
return view('user.incentive.show', [
'incentive' => $incentive,
'participant' => $participant,
@ -75,6 +86,8 @@ class IncentiveController extends Controller
'ranking' => $ranking,
'participateHasTrackableAbos' => $participateHasTrackableAbos,
'isVipView' => $user->isVIP(),
'participantPayout' => $participantPayout,
'rankingPayouts' => $rankingPayouts,
]);
}