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 4899e3a890
commit b28f51bf62
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());