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:
parent
03416b0afe
commit
e3efa2f5f0
27 changed files with 1418 additions and 129 deletions
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
137
app/Services/Incentive/IncentivePayoutService.php
Normal file
137
app/Services/Incentive/IncentivePayoutService.php
Normal 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, ',', '.').' €';
|
||||
}
|
||||
}
|
||||
|
|
@ -350,7 +350,7 @@ class IncentivePointsLogRepairService
|
|||
public function syncMissingSalesVolumeLogs(IncentiveParticipant $participant): int
|
||||
{
|
||||
$incentive = $participant->incentive;
|
||||
if (! $incentive) {
|
||||
if (! $incentive || ! $incentive->includesSalesPoints()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Recruiting-Challenge (Entwicklungsplan September 2026, E1).
|
||||
*
|
||||
* Ein Incentive kann neben Punkten eine Geldpraemie tragen: ein fester Betrag je
|
||||
* Neupartner mit Starterpaket und ein Zusatz-Bonus fuer die ersten drei Plaetze.
|
||||
* Beides ist optional (NULL = reines Punkte-Incentive wie Montenegro).
|
||||
*
|
||||
* `include_sales_points` steuert, ob die Umsatzpunkte der Neupartner und Neuabos
|
||||
* in die Wertung einfliessen. Fuer eine Recruiting-Challenge steht der Schalter
|
||||
* auf 0: die Punkte entsprechen dann der Anzahl Neupartner (Einmalpunkte 1).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('incentives', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('incentives', 'amount_per_partner')) {
|
||||
$table->decimal('amount_per_partner', 8, 2)->nullable()->after('max_winners')
|
||||
->comment('Geldpraemie je Neupartner in EUR (NULL = keine)');
|
||||
}
|
||||
if (! Schema::hasColumn('incentives', 'bonus_rank_1')) {
|
||||
$table->decimal('bonus_rank_1', 8, 2)->nullable()->after('amount_per_partner')->comment('Zusatz-Bonus Platz 1 in EUR');
|
||||
$table->decimal('bonus_rank_2', 8, 2)->nullable()->after('bonus_rank_1')->comment('Zusatz-Bonus Platz 2 in EUR');
|
||||
$table->decimal('bonus_rank_3', 8, 2)->nullable()->after('bonus_rank_2')->comment('Zusatz-Bonus Platz 3 in EUR');
|
||||
}
|
||||
if (! Schema::hasColumn('incentives', 'include_sales_points')) {
|
||||
$table->boolean('include_sales_points')->default(true)->after('bonus_rank_3')
|
||||
->comment('Umsatzpunkte der Neupartner/Neuabos mitzaehlen (0 = nur Einmalpunkte)');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('incentives', function (Blueprint $table) {
|
||||
$columns = array_values(array_filter(
|
||||
['amount_per_partner', 'bonus_rank_1', 'bonus_rank_2', 'bonus_rank_3', 'include_sales_points'],
|
||||
fn (string $column) => Schema::hasColumn('incentives', $column)
|
||||
));
|
||||
if ($columns !== []) {
|
||||
$table->dropColumn($columns);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# Entwicklungsplan – Protokoll Zoom-Meeting 08.09.2026
|
||||
|
||||
**Stand:** 18.09.2026 · **Status:** C1/C2 live seit 14.09., A4, A3 und A1+A2 umgesetzt (committet, Deploy offen); Abo-Paket damit bis auf A5 komplett. Audit vom 18.09. ([2026-09-18-audit-commits-16-09-2026.md](2026-09-18-audit-commits-16-09-2026.md)) geprüft, alle Befunde umgesetzt — Stellungnahme und Deploy-Folgen dort.
|
||||
**Stand:** 18.09.2026 · **Status:** C1/C2 live seit 14.09., A4, A3 und A1+A2 umgesetzt (committet, Deploy offen); Abo-Paket damit bis auf A5 komplett. Audit vom 18.09. ([2026-09-18-audit-commits-16-09-2026.md](2026-09-18-audit-commits-16-09-2026.md)) geprüft, alle Befunde umgesetzt — Stellungnahme und Deploy-Folgen dort. **E1 (Recruiting-Challenge) umgesetzt 18.09., Deploy offen** — Konfiguration im Admin, Details unten.
|
||||
**Quellen:** `2026 09 Protokoll September-komprimiert.pdf` (Protokoll), `2026-09-14.md` (Ticket-Entwurf Abos #1–#3)
|
||||
**Verwandte Pläne:** `dev/2026-07-22/entwicklungsplan-2026-09-07.md` (K11–K14 behandeln dieselben Themen aus dem Kundenbrief; die dortigen Rückfragen F4–F7 sind weiterhin offen), `dev/2026-09-10-business-plan/` (Ledger-Plan, löst A07/K11 langfristig)
|
||||
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
| **C2** | Laufstand-Race: Provision liest zu niedrigen Monatsstand bei zeitgleichen Buchungen – **Stufe 1+2 live seit 14.09.**, Stufe 3 offen | Befund aus C1 | 0,5 + 1 PT | hoch (Provision) | – (keine Nachzahlung, Entscheid 14.09.) |
|
||||
| **D1** | Neuer Reiter „Events“ mit Foto-Galerien (wie News-Archiv) | Protokoll | 2–3 PT | niedrig | Fotos liegen vor |
|
||||
| **D2** | Top 3 firmenweit im Backoffice | Protokoll | 0,5 PT | rechtlich | Network-Anwalt (F3) |
|
||||
| **E1** | Recruiting-Challenge Okt–Jan (50 €/Neupartner, Top 3) | Protokoll | 0,5 PT Konfiguration + 1–2 PT Erweiterung | niedrig | Details Dani/Alois |
|
||||
| **E1** | Recruiting-Challenge Okt–Jan (50 €/Neupartner, Top 3) – **umgesetzt 18.09., Deploy offen (Migration + Anlage im Admin)** | Protokoll | 0,5 PT Konfiguration + 1–2 PT Erweiterung | niedrig | Bonushöhe/Texte Dani/Alois |
|
||||
|
||||
**Reihenfolge (festgelegt 14.09.):**
|
||||
|
||||
|
|
@ -427,7 +427,7 @@ Die Zeilen entstehen in [`Payment::paymentStatusPaidAction()`](../../app/Service
|
|||
|
||||
## 5. Aktionen
|
||||
|
||||
### E1 – Recruiting-Challenge Oktober–Januar
|
||||
### E1 – Recruiting-Challenge Oktober–Januar — umgesetzt 18.09., Deploy offen
|
||||
|
||||
**Ziel (Protokoll):** Pro Neupartner mit Paket 50 € im Qualifikationszeitraum, Top 3 erhalten zusätzlich einen Bonus, Auszahlung gesammelt im Februar, wieder ein Ranking. Details klären Dani und Alois.
|
||||
|
||||
|
|
@ -441,6 +441,52 @@ Die Zeilen entstehen in [`Payment::paymentStatusPaidAction()`](../../app/Service
|
|||
|
||||
**Risiko:** niedrig. Offene Details (Bonushöhe Top 3, Anrechnung von Stornos/Rücktritten innerhalb des Zeitraums – `IncentivePointsLog.is_storno` existiert) mit Dani/Alois klären.
|
||||
|
||||
**Umsetzung (18.09.2026)**
|
||||
|
||||
Das Incentive-Modul trägt jetzt optional eine Geldprämie; Montenegro bleibt unverändert (alle neuen Felder leer bzw. Schalter an).
|
||||
|
||||
- **Neue Felder** (`incentives`, Migration `2026_09_18_130000_add_payout_fields_to_incentives_table`): `amount_per_partner` (€ je Neupartner, NULL = keine Prämie), `bonus_rank_1/2/3` (Zusatz-Bonus je Platz), `include_sales_points` (Standard 1). Admin-Formular: neue Karte „Prämie / Auszahlung“.
|
||||
- **Wertung nach Neupartnern:** Mit `include_sales_points = 0` schreibt der Tracker keine Umsatzpunkte der Neupartner/Neuabos mehr ([`IncentiveTracker::trackSalesVolume`](../../app/Services/Incentive/IncentiveTracker.php), Neuaufbau und Reparatur ebenso). Mit `points_partner_onetime = 1` und `points_abo_onetime = 0` entspricht der Punktestand damit der **Anzahl Neupartner mit Starterpaket**; die Rangliste bleibt die vorhandene (`updateRanking`). Neupartner = bezahlte Registrierung mit Starterpaket (`ShoppingOrder::wherePaidRegistrationIncludesStarterKit`), reine Mitgliedschaft zählt nicht.
|
||||
- **Prämie:** [`IncentivePayoutService`](../../app/Services/Incentive/IncentivePayoutService.php): `qualified_partners × amount_per_partner` plus Rang-Bonus, letzterer nur für qualifizierte Teilnehmer auf Platz 1–`max_winners`. Grundlage ist der gespeicherte Stand des Teilnehmers (derselbe wie in der Rangliste).
|
||||
- **Auszahlungs-Export:** Admin-Detailseite → Button „Auszahlungs-Export (XLSX)“ (`/admin/incentives/{id}/payout-export`): Rang, Berater-ID, Name, E-Mail, Teilnahme bestätigt, Neupartner, Betrag je Neupartner, Prämie Neupartner, Rang-Bonus, Prämie gesamt, Punkte, qualifiziert. Nur Zeilen mit Prämie > 0. Dazu Summenblock (Empfänger, Summen) und Prämienspalte in der Admin-Rangliste. **Gutschrift über `Credit` ist nicht gebaut** – die Februar-Auszahlung läuft über den Export in die Buchhaltung; Gutschriften ließen sich später aus derselben Liste erzeugen.
|
||||
- **Mehrere aktive Incentives:** Sidenav listet alle aktiven Incentives (bei mehreren mit Namen), Dashboard zeigt je Incentive eine Karte mit eigenen Modal-IDs; `HomeController` liefert `activeIncentives`/`incentiveParticipants` (die alten Einzelvariablen bleiben befüllt). Montenegro kann so bis zum Abschluss parallel stehen bleiben.
|
||||
- **Berater-Ansichten:** Teaser und Rangliste zeigen bei Geldprämie ohne Mindestqualifikation eine Prämien-Kachel statt „Dein Ticket“, blenden die Abo-Blöcke aus, wenn Abos keine Rolle spielen, und ergänzen die Rangliste um die Spalte „Prämie“; Header/Details zeigen „Deine Prämie bisher“. Der Punkte-Boost-Text erscheint nur, wenn Umsatzpunkte zählen.
|
||||
- **Tests:** [`IncentivePayoutTest`](../../tests/Feature/Incentive/IncentivePayoutTest.php), [`IncentiveSalesPointsToggleTest`](../../tests/Feature/Incentive/IncentiveSalesPointsToggleTest.php), [`IncentiveAdminPayoutTest`](../../tests/Feature/Incentive/IncentiveAdminPayoutTest.php). Incentive-Suite: 48 grün, 1 vorbestehender Fehlschlag (`IncentiveTeaserPageTest`, Hero-Bild in der Galerie – unabhängig von E1).
|
||||
|
||||
**Konfiguration der Challenge (Admin → Incentives → Neu):**
|
||||
|
||||
| Feld | Wert |
|
||||
|---|---|
|
||||
| Qualifikationsbeginn / -ende | 01.10.2026 / 31.01.2027 |
|
||||
| Berechnungsende | 31.01.2027 (ohne Umsatzpunkte gibt es keinen Nachlauf; 28.02. wäre nur nötig, wenn Umsätze zählen) |
|
||||
| Einmalpunkte pro Partner / pro Abo | 1 / 0 |
|
||||
| Mind. direkte Partner / Kundenabos | 0 / 0 |
|
||||
| Max. Gewinner | 3 |
|
||||
| Prämie je Neupartner | 50,00 |
|
||||
| Bonus Platz 1 / 2 / 3 | **offen – Dani/Alois** |
|
||||
| Umsatzpunkte mitzählen | **aus** |
|
||||
| Status | Aktiv (ab 01.10.; früher anlegen ist unschädlich, getrackt wird nur im Zeitraum) |
|
||||
| Bild, Untertitel, Beschreibung, Teilnahmebedingungen | **Dani/Alois** – ohne Beschreibung erscheint der Montenegro-Fallbacktext („Adria“), also Beschreibung unbedingt füllen |
|
||||
|
||||
**Nachtragen nach dem 01.10.:** `php artisan incentive:calculate {id}` zieht Neupartner ab Qualifikationsbeginn nach (läuft ohnehin täglich 05:00 für alle aktiven Incentives).
|
||||
|
||||
**Offen / bewusst nicht gebaut**
|
||||
|
||||
- Bonushöhe Top 3, Beschreibungstexte, Bild (Dani/Alois).
|
||||
- **Punktgleichstand** in den Top 3: Rangfolge ist dann bestätigte Teilnahme vor anonym, sonst Datenbankreihenfolge. Falls es auf die Boni ankommt, Regel festlegen (z. B. wer die Anzahl zuerst erreicht hat) – Umsetzung 0,5 PT.
|
||||
- **Stornos/Rücktritte:** Ein einmal getrackter Neupartner bleibt gezählt, auch wenn die Registrierung später storniert wird (Verhalten wie Montenegro). Vor der Auszahlung die Liste gegen stornierte Registrierungsbestellungen prüfen oder Regel mit Dani/Alois klären.
|
||||
- Gutschrift-Erzeugung aus dem Export (siehe oben).
|
||||
- Firmenweite Rangliste (D2) unverändert offen; die Challenge-Rangliste ist wie Montenegro für alle aktiven Berater sichtbar.
|
||||
|
||||
**Deploy**
|
||||
|
||||
```bash
|
||||
php artisan migrate # incentives: amount_per_partner, bonus_rank_1..3, include_sales_points
|
||||
php artisan view:clear && php artisan route:clear
|
||||
```
|
||||
|
||||
Danach Challenge im Admin anlegen (Tabelle oben). Kein Neuaufbau, keine Datenänderung an Montenegro.
|
||||
|
||||
---
|
||||
|
||||
## 6. Rückfragen an die Kundin / Alois (vor Start des jeweiligen Pakets)
|
||||
|
|
|
|||
|
|
@ -181,4 +181,32 @@ return [
|
|||
|
||||
// Galerie
|
||||
'gallery_title' => 'Impressionen',
|
||||
|
||||
// Praemie / Auszahlung (Recruiting-Challenge, E1)
|
||||
'payout_section' => 'Prämie / Auszahlung (Recruiting-Challenge)',
|
||||
'payout_section_help' => 'Optional. Leer lassen für ein reines Punkte-Incentive. Die Prämie wird aus der Anzahl Neupartner mit Starterpaket berechnet, der Rang-Bonus nur für qualifizierte Teilnehmer innerhalb der Gewinnerplätze.',
|
||||
'amount_per_partner' => 'Prämie je Neupartner (€)',
|
||||
'amount_per_partner_help' => 'z. B. 50,00 – wird je direkt gesponsertem Neupartner mit Starterpaket ausgezahlt.',
|
||||
'bonus_rank' => 'Bonus Platz :n (€)',
|
||||
'include_sales_points' => 'Umsatzpunkte der Neupartner/Abos mitzählen',
|
||||
'include_sales_points_help' => 'Aus: Die Wertung besteht nur aus den Einmalpunkten (z. B. 1 Punkt je Neupartner = Rangliste nach Anzahl Neupartner).',
|
||||
'payout_export' => 'Auszahlungs-Export (XLSX)',
|
||||
'payout_recipients' => 'Empfänger',
|
||||
'payout_partner_amount' => 'Prämie Neupartner',
|
||||
'payout_rank_bonus' => 'Rang-Bonus',
|
||||
'payout_total' => 'Prämie gesamt',
|
||||
'payout_column' => 'Prämie',
|
||||
'payout_incl_bonus' => 'inkl. :amount Rang-Bonus',
|
||||
'payout_so_far' => 'Deine Prämie bisher',
|
||||
'payout_tile_title' => 'Deine Prämie',
|
||||
'payout_per_partner_label' => 'je direkt gesponsertem Neupartner mit Starterpaket',
|
||||
'payout_per_partner_text' => 'Prämie: :amount je direkt gesponsertem Neupartner mit Starterpaket.',
|
||||
'payout_rank_bonus_label' => 'Zusatz-Bonus für Platz :n',
|
||||
'payout_top_bonus_title' => 'Zusatz-Bonus für die Top :n',
|
||||
'payout_top_bonus_text' => 'Zusatz-Bonus für die Top :n: :list.',
|
||||
'payout_rank_label' => 'Platz :n',
|
||||
'payout_ranking_hint' => 'Die Rangliste zählt deine Neupartner mit Starterpaket im Qualifikationszeitraum. Die Auszahlung erfolgt gesammelt nach Ende des Zeitraums.',
|
||||
'new_partners_short' => 'Neupartner',
|
||||
'badge_challenge' => 'Challenge',
|
||||
'export_user_id' => 'Berater-ID',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -181,4 +181,32 @@ return [
|
|||
|
||||
// Gallery
|
||||
'gallery_title' => 'Impressions',
|
||||
|
||||
// Praemie / Auszahlung (Recruiting-Challenge, E1)
|
||||
'payout_section' => 'Reward / payout (recruiting challenge)',
|
||||
'payout_section_help' => 'Optional. Leave empty for a points-only incentive. The reward is calculated from the number of new partners with a starter kit; the rank bonus only applies to qualified participants within the winner ranks.',
|
||||
'amount_per_partner' => 'Reward per new partner (€)',
|
||||
'amount_per_partner_help' => 'e.g. 50.00 – paid per directly sponsored new partner with a starter kit.',
|
||||
'bonus_rank' => 'Bonus rank :n (€)',
|
||||
'include_sales_points' => 'Count sales points of new partners/subscriptions',
|
||||
'include_sales_points_help' => 'Off: The score consists of one-time points only (e.g. 1 point per new partner = ranking by number of new partners).',
|
||||
'payout_export' => 'Payout export (XLSX)',
|
||||
'payout_recipients' => 'Recipients',
|
||||
'payout_partner_amount' => 'New partner reward',
|
||||
'payout_rank_bonus' => 'Rank bonus',
|
||||
'payout_total' => 'Total reward',
|
||||
'payout_column' => 'Reward',
|
||||
'payout_incl_bonus' => 'incl. :amount rank bonus',
|
||||
'payout_so_far' => 'Your reward so far',
|
||||
'payout_tile_title' => 'Your reward',
|
||||
'payout_per_partner_label' => 'per directly sponsored new partner with a starter kit',
|
||||
'payout_per_partner_text' => 'Reward: :amount per directly sponsored new partner with a starter kit.',
|
||||
'payout_rank_bonus_label' => 'Extra bonus for rank :n',
|
||||
'payout_top_bonus_title' => 'Extra bonus for the top :n',
|
||||
'payout_top_bonus_text' => 'Extra bonus for the top :n: :list.',
|
||||
'payout_rank_label' => 'Rank :n',
|
||||
'payout_ranking_hint' => 'The ranking counts your new partners with a starter kit during the qualification period. Payout is made collectively after the period ends.',
|
||||
'new_partners_short' => 'New partners',
|
||||
'badge_challenge' => 'Challenge',
|
||||
'export_user_id' => 'Consultant ID',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -181,4 +181,32 @@ return [
|
|||
|
||||
// Galería
|
||||
'gallery_title' => 'Impresiones',
|
||||
|
||||
// Praemie / Auszahlung (Recruiting-Challenge, E1)
|
||||
'payout_section' => 'Prima / pago (reto de reclutamiento)',
|
||||
'payout_section_help' => 'Opcional. Déjalo vacío para un incentivo solo de puntos. La prima se calcula por el número de nuevos socios con paquete de inicio; el bono por puesto solo aplica a participantes cualificados dentro de los puestos ganadores.',
|
||||
'amount_per_partner' => 'Prima por nuevo socio (€)',
|
||||
'amount_per_partner_help' => 'p. ej. 50,00 – se paga por cada nuevo socio patrocinado directamente con paquete de inicio.',
|
||||
'bonus_rank' => 'Bono puesto :n (€)',
|
||||
'include_sales_points' => 'Contar puntos de ventas de nuevos socios/suscripciones',
|
||||
'include_sales_points_help' => 'Desactivado: la puntuación consta solo de los puntos únicos (p. ej. 1 punto por nuevo socio = ranking por número de nuevos socios).',
|
||||
'payout_export' => 'Exportación de pagos (XLSX)',
|
||||
'payout_recipients' => 'Destinatarios',
|
||||
'payout_partner_amount' => 'Prima nuevos socios',
|
||||
'payout_rank_bonus' => 'Bono por puesto',
|
||||
'payout_total' => 'Prima total',
|
||||
'payout_column' => 'Prima',
|
||||
'payout_incl_bonus' => 'incl. :amount bono por puesto',
|
||||
'payout_so_far' => 'Tu prima hasta ahora',
|
||||
'payout_tile_title' => 'Tu prima',
|
||||
'payout_per_partner_label' => 'por nuevo socio patrocinado directamente con paquete de inicio',
|
||||
'payout_per_partner_text' => 'Prima: :amount por nuevo socio patrocinado directamente con paquete de inicio.',
|
||||
'payout_rank_bonus_label' => 'Bono adicional para el puesto :n',
|
||||
'payout_top_bonus_title' => 'Bono adicional para el top :n',
|
||||
'payout_top_bonus_text' => 'Bono adicional para el top :n: :list.',
|
||||
'payout_rank_label' => 'Puesto :n',
|
||||
'payout_ranking_hint' => 'El ranking cuenta tus nuevos socios con paquete de inicio durante el periodo de cualificación. El pago se realiza de forma conjunta al finalizar el periodo.',
|
||||
'new_partners_short' => 'Nuevos socios',
|
||||
'badge_challenge' => 'Reto',
|
||||
'export_user_id' => 'ID del asesor',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -156,4 +156,32 @@ return [
|
|||
'onetime_abo_activation' => 'Unique : activation abonnement',
|
||||
'accumulated' => 'Chiffre d’affaires',
|
||||
'gallery_title' => 'Impressions',
|
||||
|
||||
// Praemie / Auszahlung (Recruiting-Challenge, E1)
|
||||
'payout_section' => 'Prime / versement (challenge de recrutement)',
|
||||
'payout_section_help' => 'Facultatif. Laisser vide pour un incentive à points uniquement. La prime est calculée d’après le nombre de nouveaux partenaires avec kit de démarrage ; le bonus de classement ne s’applique qu’aux participants qualifiés dans les places gagnantes.',
|
||||
'amount_per_partner' => 'Prime par nouveau partenaire (€)',
|
||||
'amount_per_partner_help' => 'p. ex. 50,00 – versée par nouveau partenaire parrainé directement avec kit de démarrage.',
|
||||
'bonus_rank' => 'Bonus place :n (€)',
|
||||
'include_sales_points' => 'Compter les points de vente des nouveaux partenaires/abonnements',
|
||||
'include_sales_points_help' => 'Désactivé : le score ne comprend que les points uniques (p. ex. 1 point par nouveau partenaire = classement par nombre de nouveaux partenaires).',
|
||||
'payout_export' => 'Export des versements (XLSX)',
|
||||
'payout_recipients' => 'Bénéficiaires',
|
||||
'payout_partner_amount' => 'Prime nouveaux partenaires',
|
||||
'payout_rank_bonus' => 'Bonus de classement',
|
||||
'payout_total' => 'Prime totale',
|
||||
'payout_column' => 'Prime',
|
||||
'payout_incl_bonus' => 'dont :amount de bonus de classement',
|
||||
'payout_so_far' => 'Ta prime jusqu’ici',
|
||||
'payout_tile_title' => 'Ta prime',
|
||||
'payout_per_partner_label' => 'par nouveau partenaire parrainé directement avec kit de démarrage',
|
||||
'payout_per_partner_text' => 'Prime : :amount par nouveau partenaire parrainé directement avec kit de démarrage.',
|
||||
'payout_rank_bonus_label' => 'Bonus supplémentaire pour la place :n',
|
||||
'payout_top_bonus_title' => 'Bonus supplémentaire pour le top :n',
|
||||
'payout_top_bonus_text' => 'Bonus supplémentaire pour le top :n : :list.',
|
||||
'payout_rank_label' => 'Place :n',
|
||||
'payout_ranking_hint' => 'Le classement compte tes nouveaux partenaires avec kit de démarrage pendant la période de qualification. Le versement est effectué groupé après la fin de la période.',
|
||||
'new_partners_short' => 'Nouveaux partenaires',
|
||||
'badge_challenge' => 'Challenge',
|
||||
'export_user_id' => 'ID du conseiller',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -121,6 +121,43 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ===== PRAEMIE / AUSZAHLUNG (Recruiting-Challenge) ===== --}}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<i class="ion ion-md-cash mr-1"></i>
|
||||
<strong>{{ __('incentive.payout_section') }}</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small">{{ __('incentive.payout_section_help') }}</p>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group col-md-3">
|
||||
<label for="amount_per_partner">{{ __('incentive.amount_per_partner') }}</label>
|
||||
<input type="number" step="0.01" min="0" class="form-control" id="amount_per_partner"
|
||||
name="amount_per_partner"
|
||||
value="{{ old('amount_per_partner', isset($incentive->amount_per_partner) ? number_format((float) $incentive->amount_per_partner, 2, '.', '') : '') }}">
|
||||
<small class="form-text text-muted">{{ __('incentive.amount_per_partner_help') }}</small>
|
||||
</div>
|
||||
@for ($bonusRank = 1; $bonusRank <= \App\Models\Incentive::RANK_BONUS_SLOTS; $bonusRank++)
|
||||
<div class="form-group col-md-3">
|
||||
<label for="bonus_rank_{{ $bonusRank }}">{{ __('incentive.bonus_rank', ['n' => $bonusRank]) }}</label>
|
||||
<input type="number" step="0.01" min="0" class="form-control" id="bonus_rank_{{ $bonusRank }}"
|
||||
name="bonus_rank_{{ $bonusRank }}"
|
||||
value="{{ old('bonus_rank_' . $bonusRank, isset($incentive) && $incentive->{'bonus_rank_' . $bonusRank} !== null ? number_format((float) $incentive->{'bonus_rank_' . $bonusRank}, 2, '.', '') : '') }}">
|
||||
</div>
|
||||
@endfor
|
||||
</div>
|
||||
|
||||
<div class="custom-control custom-checkbox">
|
||||
<input type="hidden" name="include_sales_points" value="0">
|
||||
<input type="checkbox" class="custom-control-input" id="include_sales_points" name="include_sales_points"
|
||||
value="1" @if (old('include_sales_points', isset($incentive) ? (int) $incentive->includesSalesPoints() : 1)) checked @endif>
|
||||
<label class="custom-control-label" for="include_sales_points">{{ __('incentive.include_sales_points') }}</label>
|
||||
</div>
|
||||
<small class="form-text text-muted">{{ __('incentive.include_sales_points_help') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ===== INHALTE: DEUTSCH (Standard) ===== --}}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-primary text-white">
|
||||
|
|
|
|||
|
|
@ -8,6 +8,12 @@
|
|||
<a href="{{ route('admin_incentive_edit', [$incentive->id]) }}" class="btn btn-sm btn-warning">
|
||||
<span class="fa fa-edit"></span> {{ __('incentive.edit') }}
|
||||
</a>
|
||||
@if ($incentive->hasCashPayout())
|
||||
<a href="{{ route('admin_incentive_payout_export', [$incentive->id]) }}" class="btn btn-sm btn-success"
|
||||
id="incentive-payout-export">
|
||||
<span class="fa fa-file-excel"></span> {{ __('incentive.payout_export') }}
|
||||
</a>
|
||||
@endif
|
||||
{{-- <form action="{{ route('admin_incentive_recalculate', [$incentive->id]) }}" method="POST" class="d-inline">
|
||||
@csrf
|
||||
<button type="submit" class="btn btn-sm btn-info" onclick="return confirm('{{ __('incentive.recalculate_confirm') }}')">
|
||||
|
|
@ -45,9 +51,37 @@
|
|||
</div>
|
||||
<div class="col-md-4">
|
||||
<strong>{{ __('incentive.min_direct_partners') }}:</strong> {{ $incentive->min_direct_partners }}<br>
|
||||
<strong>{{ __('incentive.min_customer_abos') }}:</strong> {{ $incentive->min_customer_abos }}
|
||||
<strong>{{ __('incentive.min_customer_abos') }}:</strong> {{ $incentive->min_customer_abos }}<br>
|
||||
<strong>{{ __('incentive.include_sales_points') }}:</strong>
|
||||
{{ $incentive->includesSalesPoints() ? __('incentive.yes') : __('incentive.no') }}
|
||||
</div>
|
||||
</div>
|
||||
@if ($incentive->hasCashPayout())
|
||||
<hr>
|
||||
<div class="row" id="incentive-payout-config">
|
||||
<div class="col-md-4">
|
||||
<strong>{{ __('incentive.amount_per_partner') }}:</strong>
|
||||
{{ \App\Services\Incentive\IncentivePayoutService::formatAmount((float) $incentive->amount_per_partner) }}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
@foreach ($incentive->rankBonuses() as $bonusRank => $bonusAmount)
|
||||
<strong>{{ __('incentive.bonus_rank', ['n' => $bonusRank]) }}:</strong>
|
||||
{{ \App\Services\Incentive\IncentivePayoutService::formatAmount($bonusAmount) }}<br>
|
||||
@endforeach
|
||||
</div>
|
||||
@if ($payoutTotals)
|
||||
<div class="col-md-4">
|
||||
<strong>{{ __('incentive.payout_recipients') }}:</strong> {{ $payoutTotals['recipients'] }}<br>
|
||||
<strong>{{ __('incentive.payout_partner_amount') }}:</strong>
|
||||
{{ \App\Services\Incentive\IncentivePayoutService::formatAmount($payoutTotals['partner_amount']) }}<br>
|
||||
<strong>{{ __('incentive.payout_rank_bonus') }}:</strong>
|
||||
{{ \App\Services\Incentive\IncentivePayoutService::formatAmount($payoutTotals['rank_bonus']) }}<br>
|
||||
<strong>{{ __('incentive.payout_total') }}:</strong>
|
||||
<span class="font-weight-bold">{{ \App\Services\Incentive\IncentivePayoutService::formatAmount($payoutTotals['total']) }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -68,6 +102,9 @@
|
|||
<th>{{ __('incentive.partners') }}</th>
|
||||
<th>{{ __('incentive.abos') }}</th>
|
||||
<th>{{ __('incentive.qualified') }}</th>
|
||||
@if ($incentive->hasCashPayout())
|
||||
<th class="text-right">{{ __('incentive.payout_total') }}</th>
|
||||
@endif
|
||||
<th>{{ __('incentive.status') }}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
|
|
@ -117,6 +154,19 @@
|
|||
<span class="badge badge-secondary">{{ __('incentive.no') }}</span>
|
||||
@endif
|
||||
</td>
|
||||
@if ($incentive->hasCashPayout())
|
||||
@php $payout = $payoutByParticipantId[$p->id] ?? null; @endphp
|
||||
<td class="text-right">
|
||||
@if ($payout && $payout['total'] > 0)
|
||||
{{ \App\Services\Incentive\IncentivePayoutService::formatAmount($payout['total']) }}
|
||||
@if ($payout['rank_bonus'] > 0)
|
||||
<small class="text-muted d-block">{{ __('incentive.payout_incl_bonus', ['amount' => \App\Services\Incentive\IncentivePayoutService::formatAmount($payout['rank_bonus'])]) }}</small>
|
||||
@endif
|
||||
@else
|
||||
—
|
||||
@endif
|
||||
</td>
|
||||
@endif
|
||||
<td>
|
||||
@if ($isWinner)
|
||||
<span class="badge badge-warning">{{ __('incentive.winner') }}</span>
|
||||
|
|
@ -136,7 +186,7 @@
|
|||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="10" class="text-center text-muted">{{ __('incentive.no_participants') }}</td>
|
||||
<td colspan="{{ $incentive->hasCashPayout() ? 11 : 10 }}" class="text-center text-muted">{{ __('incentive.no_participants') }}</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,19 @@
|
|||
@if (isset($activeIncentive) && $activeIncentive)
|
||||
@php
|
||||
// Mehrere aktive Incentives parallel (E1): Fallback auf die alte Einzelvariable.
|
||||
$dashIncentives = isset($activeIncentives) && $activeIncentives
|
||||
? $activeIncentives
|
||||
: collect(isset($activeIncentive) && $activeIncentive ? [$activeIncentive] : []);
|
||||
@endphp
|
||||
@foreach ($dashIncentives as $activeIncentive)
|
||||
@php
|
||||
$incentiveParticipant = isset($incentiveParticipants) && $incentiveParticipants
|
||||
? $incentiveParticipants->get($activeIncentive->id)
|
||||
: ($incentiveParticipant ?? null);
|
||||
$hasConfirmedDash = $incentiveParticipant && $incentiveParticipant->accepted_terms_at !== null;
|
||||
$dashSuffix = '-' . $activeIncentive->id;
|
||||
$dashPayout = $activeIncentive->hasCashPayout() && $incentiveParticipant
|
||||
? (new \App\Services\Incentive\IncentivePayoutService)->forParticipant($activeIncentive, $incentiveParticipant)
|
||||
: null;
|
||||
@endphp
|
||||
<div class="d-flex col-xl-12 align-items-stretch">
|
||||
<div class="w-100 mb-4 inc-dash-widget">
|
||||
|
|
@ -12,7 +25,7 @@
|
|||
<div class="inc-dash-hero-overlay"></div>
|
||||
<div class="inc-dash-hero-content">
|
||||
<span class="inc-dash-badge">
|
||||
<i class="ion ion-md-trophy mr-1"></i> Incentive VIP
|
||||
<i class="ion ion-md-trophy mr-1"></i> {{ $activeIncentive->hasCashPayout() ? __('incentive.badge_challenge') : 'Incentive VIP' }}
|
||||
</span>
|
||||
<h4 class="inc-dash-title">
|
||||
{{ $activeIncentive->getLang('name') ?: $activeIncentive->name }}
|
||||
|
|
@ -25,7 +38,7 @@
|
|||
@else
|
||||
<div class="inc-dash-header-plain">
|
||||
<span class="inc-dash-badge">
|
||||
<i class="ion ion-md-trophy mr-1"></i> Incentive VIP
|
||||
<i class="ion ion-md-trophy mr-1"></i> {{ $activeIncentive->hasCashPayout() ? __('incentive.badge_challenge') : 'Incentive VIP' }}
|
||||
</span>
|
||||
<h4 class="font-weight-bold mb-1">
|
||||
{{ $activeIncentive->getLang('name') ?: $activeIncentive->name }}
|
||||
|
|
@ -51,8 +64,14 @@
|
|||
<div class="inc-dash-stat">
|
||||
<span
|
||||
class="inc-dash-stat-value">{{ number_format($incentiveParticipant->total_points, 0, ',', '.') }}</span>
|
||||
<span class="inc-dash-stat-label">{{ __('incentive.points_short') }}</span>
|
||||
<span class="inc-dash-stat-label">{{ $activeIncentive->ranksByPartnerCount() ? __('incentive.new_partners_short') : __('incentive.points_short') }}</span>
|
||||
</div>
|
||||
@if ($dashPayout)
|
||||
<div class="inc-dash-stat">
|
||||
<span class="inc-dash-stat-value">{{ \App\Services\Incentive\IncentivePayoutService::formatAmount($dashPayout['total']) }}</span>
|
||||
<span class="inc-dash-stat-label">{{ __('incentive.payout_so_far') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
<div class="inc-dash-stat">
|
||||
@if ($incentiveParticipant->is_qualified)
|
||||
<span class="inc-dash-qual-yes">{{ __('incentive.qualified') }}</span>
|
||||
|
|
@ -80,7 +99,7 @@
|
|||
@endif
|
||||
@if ($activeIncentive->isActive())
|
||||
<button type="button" class="btn inc-dash-btn-notice" data-toggle="modal"
|
||||
data-target="#incParticipateModal">
|
||||
data-target="#incParticipateModal{{ $dashSuffix }}">
|
||||
<i class="ion ion-md-checkmark-circle mr-1"></i>
|
||||
{{ __('incentive.dash_notice_btn') }}
|
||||
</button>
|
||||
|
|
@ -145,12 +164,12 @@
|
|||
|
||||
{{-- Teilnahme-Modal --}}
|
||||
@if (!$hasConfirmedDash && $activeIncentive->isActive())
|
||||
<div class="modal fade" id="incParticipateModal" tabindex="-1" role="dialog"
|
||||
aria-labelledby="incParticipateModalLabel" aria-hidden="true">
|
||||
<div class="modal fade" id="incParticipateModal{{ $dashSuffix }}" tabindex="-1" role="dialog"
|
||||
aria-labelledby="incParticipateModalLabel{{ $dashSuffix }}" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header" style="background: linear-gradient(135deg, #6b7758, #4a5340);">
|
||||
<h5 class="modal-title text-white" id="incParticipateModalLabel">
|
||||
<h5 class="modal-title text-white" id="incParticipateModalLabel{{ $dashSuffix }}">
|
||||
<i class="ion ion-md-trophy mr-2" style="color: #d7d700;"></i>
|
||||
{{ __('incentive.dash_modal_title') }}
|
||||
</h5>
|
||||
|
|
@ -158,7 +177,7 @@
|
|||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<form id="incDashParticipateForm"
|
||||
<form id="incDashParticipateForm{{ $dashSuffix }}"
|
||||
action="{{ route('user_incentive_participate', [$activeIncentive->slug]) }}" method="POST">
|
||||
@csrf
|
||||
<div class="modal-body">
|
||||
|
|
@ -174,14 +193,14 @@
|
|||
@if ($activeIncentive->getLang('terms'))
|
||||
<div class="card mb-4" style="border: 1px solid #e0e0d8;">
|
||||
<div class="card-header py-2 px-3" style="cursor: pointer; background: #f4f5f0;"
|
||||
data-toggle="collapse" data-target="#dashTermsCollapse">
|
||||
data-toggle="collapse" data-target="#dashTermsCollapse{{ $dashSuffix }}">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="ion ion-md-document mr-2" style="color: #6b7758;"></i>
|
||||
<strong class="small">{{ __('incentive.terms') }}</strong>
|
||||
<i class="ion ion-md-chevron-down ml-auto text-muted"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div id="dashTermsCollapse" class="collapse">
|
||||
<div id="dashTermsCollapse{{ $dashSuffix }}" class="collapse">
|
||||
<div class="card-body small"
|
||||
style="max-height: 280px; overflow-y: auto; line-height: 1.6;">
|
||||
{!! $activeIncentive->getLang('terms') !!}
|
||||
|
|
@ -191,12 +210,12 @@
|
|||
@endif
|
||||
|
||||
<div class="custom-control custom-checkbox">
|
||||
<input type="checkbox" class="custom-control-input" id="dashAcceptTerms"
|
||||
<input type="checkbox" class="custom-control-input" id="dashAcceptTerms{{ $dashSuffix }}"
|
||||
name="accept_terms" value="1" required>
|
||||
<label class="custom-control-label" for="dashAcceptTerms">
|
||||
<label class="custom-control-label" for="dashAcceptTerms{{ $dashSuffix }}">
|
||||
{{ __('incentive.accept_terms') }}
|
||||
@if ($activeIncentive->getLang('terms'))
|
||||
(<a href="#dashTermsCollapse" data-toggle="collapse"
|
||||
(<a href="#dashTermsCollapse{{ $dashSuffix }}" data-toggle="collapse"
|
||||
class="text-muted">{{ __('incentive.show_terms') }}</a>)
|
||||
@endif
|
||||
</label>
|
||||
|
|
@ -217,6 +236,9 @@
|
|||
</div>
|
||||
@endif
|
||||
|
||||
@endforeach
|
||||
|
||||
@if ($dashIncentives->isNotEmpty())
|
||||
<style>
|
||||
.inc-dash-notice {
|
||||
background: rgba(215, 185, 0, 0.10);
|
||||
|
|
|
|||
|
|
@ -290,15 +290,15 @@
|
|||
<div>{{ __('navigation.downloadcenter') }}</div>
|
||||
</a>
|
||||
</li>
|
||||
@php $activeIncentive = \App\Models\Incentive::active()->first(); @endphp
|
||||
@if ($activeIncentive)
|
||||
<li class="sidenav-item{{ Request::is('incentive/*') ? ' active' : '' }}">
|
||||
<a href="{{ route('user_incentive_teaser', [$activeIncentive->slug]) }}"
|
||||
@php $sidenavIncentives = \App\Models\Incentive::activeForNavigation(); @endphp
|
||||
@foreach ($sidenavIncentives as $sidenavIncentive)
|
||||
<li class="sidenav-item{{ Request::is('incentive/' . $sidenavIncentive->slug . '*') ? ' active' : '' }}">
|
||||
<a href="{{ route('user_incentive_teaser', [$sidenavIncentive->slug]) }}"
|
||||
class="sidenav-link"><i class="text-secondary sidenav-icon ion ion-md-trophy"></i>
|
||||
<div>{{ __('navigation.incentive') }}</div>
|
||||
<div>{{ $sidenavIncentives->count() > 1 ? ($sidenavIncentive->getLang('name') ?: $sidenavIncentive->name) : __('navigation.incentive') }}</div>
|
||||
</a>
|
||||
</li>
|
||||
@endif
|
||||
@endforeach
|
||||
@endif
|
||||
@endif
|
||||
|
||||
|
|
|
|||
7
resources/views/user/incentive/_payout_bullets.blade.php
Normal file
7
resources/views/user/incentive/_payout_bullets.blade.php
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{{-- Praemien-Hinweise in der Punkte-Erklaerung (nur bei Geldpraemie). Erwartet $incentive. --}}
|
||||
@if ((float) $incentive->amount_per_partner > 0)
|
||||
<li>{{ __('incentive.payout_per_partner_text', ['amount' => \App\Services\Incentive\IncentivePayoutService::formatAmount((float) $incentive->amount_per_partner)]) }}</li>
|
||||
@endif
|
||||
@if ($incentive->rankBonuses() !== [])
|
||||
<li>{{ __('incentive.payout_top_bonus_text', ['n' => count($incentive->rankBonuses()), 'list' => collect($incentive->rankBonuses())->map(fn ($amount, $rank) => __('incentive.payout_rank_label', ['n' => $rank]).' '.\App\Services\Incentive\IncentivePayoutService::formatAmount($amount))->implode(', ')]) }}</li>
|
||||
@endif
|
||||
31
resources/views/user/incentive/_payout_tile.blade.php
Normal file
31
resources/views/user/incentive/_payout_tile.blade.php
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{{-- Praemien-Kachel einer Recruiting-Challenge (ersetzt die Mindestqualifikation, wenn keine gefordert ist). Erwartet $incentive. --}}
|
||||
<div class="card inc-info-tile shadow-sm mb-4" id="incentive-payout-tile">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-start">
|
||||
<div class="inc-tile-icon inc-tile-icon-plane mr-3">
|
||||
<i class="ion ion-md-cash"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h6 class="font-weight-bold mb-2">{{ __('incentive.payout_tile_title') }}</h6>
|
||||
<ul class="list-unstyled mb-2">
|
||||
@if ((float) $incentive->amount_per_partner > 0)
|
||||
<li class="mb-1">
|
||||
<strong style="color: #6b7758;">{{ \App\Services\Incentive\IncentivePayoutService::formatAmount((float) $incentive->amount_per_partner) }}</strong>
|
||||
{{ __('incentive.payout_per_partner_label') }}
|
||||
</li>
|
||||
@endif
|
||||
@foreach ($incentive->rankBonuses() as $bonusRank => $bonusAmount)
|
||||
<li>
|
||||
<strong style="color: #6b7758;">{{ \App\Services\Incentive\IncentivePayoutService::formatAmount($bonusAmount) }}</strong>
|
||||
{{ __('incentive.payout_rank_bonus_label', ['n' => $bonusRank]) }}
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
<p class="text-muted small mb-0">
|
||||
<i class="ion ion-md-information-circle mr-1"></i>
|
||||
{{ __('incentive.payout_ranking_hint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -31,21 +31,36 @@
|
|||
<div class="card-body">
|
||||
<h5 class="card-title text-muted">{{ __('incentive.partners') }}</h5>
|
||||
<h2 class="font-weight-bold {{ $participant->qualified_partners >= $incentive->min_direct_partners ? 'text-success' : '' }}">
|
||||
{{ $participant->qualified_partners }}/{{ $incentive->min_direct_partners }}
|
||||
{{ $participant->qualified_partners }}@if ($incentive->min_direct_partners > 0)/{{ $incentive->min_direct_partners }}@endif
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card text-center">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-muted">{{ __('incentive.abos') }}</h5>
|
||||
<h2 class="font-weight-bold {{ $participant->qualified_abos >= $incentive->min_customer_abos ? 'text-success' : '' }}">
|
||||
{{ $participant->qualified_abos }}/{{ $incentive->min_customer_abos }}
|
||||
</h2>
|
||||
@if ($incentive->hasCashPayout() && !$incentive->usesAbos())
|
||||
@php $detailPayout = (new \App\Services\Incentive\IncentivePayoutService)->forParticipant($incentive, $participant); @endphp
|
||||
<div class="col-md-3">
|
||||
<div class="card text-center" id="incentive-payout-card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-muted">{{ __('incentive.payout_so_far') }}</h5>
|
||||
<h2 class="font-weight-bold">{{ \App\Services\Incentive\IncentivePayoutService::formatAmount($detailPayout['total']) }}</h2>
|
||||
@if ($detailPayout['rank_bonus'] > 0)
|
||||
<small class="text-muted">{{ __('incentive.payout_incl_bonus', ['amount' => \App\Services\Incentive\IncentivePayoutService::formatAmount($detailPayout['rank_bonus'])]) }}</small>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="col-md-3">
|
||||
<div class="card text-center">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-muted">{{ __('incentive.abos') }}</h5>
|
||||
<h2 class="font-weight-bold {{ $participant->qualified_abos >= $incentive->min_customer_abos ? 'text-success' : '' }}">
|
||||
{{ $participant->qualified_abos }}/{{ $incentive->min_customer_abos }}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Sektion A: Neupartner-Punkte --}}
|
||||
|
|
@ -64,21 +79,23 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Sektion B: Kundenabo-Punkte --}}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<strong>{{ __('incentive.section_abos') }}</strong>
|
||||
{{-- Sektion B: Kundenabo-Punkte (entfaellt bei reiner Recruiting-Challenge) --}}
|
||||
@if ($incentive->usesAbos() || $abo_sources->isNotEmpty())
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<strong>{{ __('incentive.section_abos') }}</strong>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
@include('partials.incentive._source_table', [
|
||||
'sources' => $abo_sources,
|
||||
'type' => 'abo',
|
||||
'label_header' => __('incentive.customer_abo'),
|
||||
'date_header' => __('incentive.abo_date'),
|
||||
'empty_message' => __('incentive.no_abos_yet'),
|
||||
])
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
@include('partials.incentive._source_table', [
|
||||
'sources' => $abo_sources,
|
||||
'type' => 'abo',
|
||||
'label_header' => __('incentive.customer_abo'),
|
||||
'date_header' => __('incentive.abo_date'),
|
||||
'empty_message' => __('incentive.no_abos_yet'),
|
||||
])
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Gesamtpunkte --}}
|
||||
<div class="card border-primary">
|
||||
|
|
|
|||
|
|
@ -490,9 +490,15 @@
|
|||
</div>
|
||||
@endif
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">{{ __('incentive.total_points') }}</div>
|
||||
<div class="stat-label">{{ $incentive->ranksByPartnerCount() ? __('incentive.new_partners_short') : __('incentive.total_points') }}</div>
|
||||
<div class="stat-value">{{ number_format($participant->total_points, 0, ',', '.') }}</div>
|
||||
</div>
|
||||
@if ($participantPayout)
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">{{ __('incentive.payout_so_far') }}</div>
|
||||
<div class="stat-value">{{ \App\Services\Incentive\IncentivePayoutService::formatAmount($participantPayout['total']) }}</div>
|
||||
</div>
|
||||
@endif
|
||||
<div class="mr-3">
|
||||
@if ($participant->is_qualified)
|
||||
<span class="badge-qual">{{ __('incentive.qualified') }}</span>
|
||||
|
|
@ -560,6 +566,9 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
@if ($incentive->hasCashPayout() && !$incentive->hasMinimumRequirements())
|
||||
@include('user.incentive._payout_tile', ['incentive' => $incentive])
|
||||
@else
|
||||
{{-- Mindestqualifikation --}}
|
||||
<div class="card inc-info-tile shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
|
|
@ -580,14 +589,15 @@
|
|||
{{ __('incentive.min_abos_label') }}
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-muted small mb-0">
|
||||
<i class="ion ion-md-information-circle mr-1"></i>
|
||||
{{ __('incentive.min_qual_ranking_hint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-muted small mb-0">
|
||||
<i class="ion ion-md-information-circle mr-1"></i>
|
||||
{{ __('incentive.min_qual_ranking_hint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Punkte-System --}}
|
||||
<div class="inc-points-section mb-4">
|
||||
|
|
@ -596,7 +606,7 @@
|
|||
{{ __('incentive.section_points') }}
|
||||
</h6>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3 mb-md-0">
|
||||
<div class="{{ $incentive->usesAbos() ? 'col-md-6 mb-3 mb-md-0' : 'col-md-12' }}">
|
||||
<div class="inc-point-card inc-point-card-partner">
|
||||
<h6 class="font-weight-bold mb-1" style="font-size: .9rem;">
|
||||
<i class="ion ion-md-person-add mr-1"></i>
|
||||
|
|
@ -609,10 +619,14 @@
|
|||
<ul class="pl-3 mb-0">
|
||||
<li>{{ __('incentive.points_onetime_label') }}</li>
|
||||
<li>{{ __('incentive.points_starter_package_label') }}</li>
|
||||
<li>{{ __('incentive.points_partner_boost') }}</li>
|
||||
@if ($incentive->includesSalesPoints())
|
||||
<li>{{ __('incentive.points_partner_boost') }}</li>
|
||||
@endif
|
||||
@include('user.incentive._payout_bullets', ['incentive' => $incentive])
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@if ($incentive->usesAbos())
|
||||
<div class="col-md-6">
|
||||
<div class="inc-point-card inc-point-card-abo">
|
||||
<h6 class="font-weight-bold mb-1" style="font-size: .9rem;">
|
||||
|
|
@ -626,10 +640,13 @@
|
|||
<ul class="pl-3 mb-0">
|
||||
<li>{{ __('incentive.points_onetime_label') }}</li>
|
||||
<li>{{ __('incentive.points_abo_direct') }}</li>
|
||||
<li>{{ __('incentive.points_abo_boost') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@if ($incentive->includesSalesPoints())
|
||||
<li>{{ __('incentive.points_abo_boost') }}</li>
|
||||
@endif
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -694,9 +711,15 @@
|
|||
</div>
|
||||
@endif
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">{{ __('incentive.total_points') }}</div>
|
||||
<div class="stat-label">{{ $incentive->ranksByPartnerCount() ? __('incentive.new_partners_short') : __('incentive.total_points') }}</div>
|
||||
<div class="stat-value">{{ number_format($participant->total_points, 0, ',', '.') }}</div>
|
||||
</div>
|
||||
@if ($participantPayout)
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">{{ __('incentive.payout_so_far') }}</div>
|
||||
<div class="stat-value">{{ \App\Services\Incentive\IncentivePayoutService::formatAmount($participantPayout['total']) }}</div>
|
||||
</div>
|
||||
@endif
|
||||
<div>
|
||||
@if ($participant->is_qualified)
|
||||
<span class="badge-qual">{{ __('incentive.qualified') }}</span>
|
||||
|
|
@ -759,6 +782,9 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
@if ($incentive->hasCashPayout() && !$incentive->hasMinimumRequirements())
|
||||
@include('user.incentive._payout_tile', ['incentive' => $incentive])
|
||||
@else
|
||||
{{-- Mindestqualifikation --}}
|
||||
<div class="card inc-info-tile shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
|
|
@ -779,14 +805,15 @@
|
|||
{{ __('incentive.min_abos_label') }}
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-muted small mb-0">
|
||||
<i class="ion ion-md-information-circle mr-1"></i>
|
||||
{{ __('incentive.min_qual_ranking_hint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-muted small mb-0">
|
||||
<i class="ion ion-md-information-circle mr-1"></i>
|
||||
{{ __('incentive.min_qual_ranking_hint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Punkte-System --}}
|
||||
<div class="inc-points-section mb-4">
|
||||
|
|
@ -795,7 +822,7 @@
|
|||
{{ __('incentive.section_points') }}
|
||||
</h6>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3 mb-md-0">
|
||||
<div class="{{ $incentive->usesAbos() ? 'col-md-6 mb-3 mb-md-0' : 'col-md-12' }}">
|
||||
<div class="inc-point-card inc-point-card-partner">
|
||||
<h6 class="font-weight-bold mb-1" style="font-size: .9rem;">
|
||||
<i class="ion ion-md-person-add mr-1"></i>
|
||||
|
|
@ -807,10 +834,15 @@
|
|||
</div>
|
||||
<ul class="pl-3 mb-0">
|
||||
<li>{{ __('incentive.points_onetime_label') }}</li>
|
||||
<li>{{ __('incentive.points_partner_boost') }}</li>
|
||||
<li>{{ __('incentive.points_starter_package_label') }}</li>
|
||||
@if ($incentive->includesSalesPoints())
|
||||
<li>{{ __('incentive.points_partner_boost') }}</li>
|
||||
@endif
|
||||
@include('user.incentive._payout_bullets', ['incentive' => $incentive])
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@if ($incentive->usesAbos())
|
||||
<div class="col-md-6">
|
||||
<div class="inc-point-card inc-point-card-abo">
|
||||
<h6 class="font-weight-bold mb-1" style="font-size: .9rem;">
|
||||
|
|
@ -823,10 +855,13 @@
|
|||
</div>
|
||||
<ul class="pl-3 mb-0">
|
||||
<li>{{ __('incentive.points_onetime_label') }}</li>
|
||||
<li>{{ __('incentive.points_abo_boost') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@if ($incentive->includesSalesPoints())
|
||||
<li>{{ __('incentive.points_abo_boost') }}</li>
|
||||
@endif
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -929,9 +964,16 @@
|
|||
<tr>
|
||||
<th style="width: 60px;">{{ __('incentive.rank') }}</th>
|
||||
<th>{{ __('incentive.consultant') }}</th>
|
||||
<th class="text-right">{{ __('incentive.total_points') }}</th>
|
||||
<th class="text-center">{{ __('incentive.partners') }}</th>
|
||||
<th class="text-center">{{ __('incentive.abos') }}</th>
|
||||
<th class="text-right">{{ $incentive->ranksByPartnerCount() ? __('incentive.new_partners_short') : __('incentive.total_points') }}</th>
|
||||
@if (!$incentive->ranksByPartnerCount())
|
||||
<th class="text-center">{{ __('incentive.partners') }}</th>
|
||||
@endif
|
||||
@if ($incentive->usesAbos())
|
||||
<th class="text-center">{{ __('incentive.abos') }}</th>
|
||||
@endif
|
||||
@if ($incentive->hasCashPayout())
|
||||
<th class="text-right">{{ __('incentive.payout_column') }}</th>
|
||||
@endif
|
||||
<th class="text-center">{{ __('incentive.status') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -978,18 +1020,32 @@
|
|||
<td class="text-right">
|
||||
<strong>{{ number_format($p->total_points, 0, ',', '.') }}</strong>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{{ $p->qualified_partners }}/{{ $incentive->min_direct_partners }}
|
||||
@if ($p->qualified_partners >= $incentive->min_direct_partners)
|
||||
<span class="check-ok">✓</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{{ $p->qualified_abos }}/{{ $incentive->min_customer_abos }}
|
||||
@if ($p->qualified_abos >= $incentive->min_customer_abos)
|
||||
<span class="check-ok">✓</span>
|
||||
@endif
|
||||
</td>
|
||||
@if (!$incentive->ranksByPartnerCount())
|
||||
<td class="text-center">
|
||||
{{ $p->qualified_partners }}@if ($incentive->min_direct_partners > 0)/{{ $incentive->min_direct_partners }}@endif
|
||||
@if ($incentive->min_direct_partners > 0 && $p->qualified_partners >= $incentive->min_direct_partners)
|
||||
<span class="check-ok">✓</span>
|
||||
@endif
|
||||
</td>
|
||||
@endif
|
||||
@if ($incentive->usesAbos())
|
||||
<td class="text-center">
|
||||
{{ $p->qualified_abos }}@if ($incentive->min_customer_abos > 0)/{{ $incentive->min_customer_abos }}@endif
|
||||
@if ($incentive->min_customer_abos > 0 && $p->qualified_abos >= $incentive->min_customer_abos)
|
||||
<span class="check-ok">✓</span>
|
||||
@endif
|
||||
</td>
|
||||
@endif
|
||||
@if ($incentive->hasCashPayout())
|
||||
@php $rowPayout = $rankingPayouts[$p->id] ?? null; @endphp
|
||||
<td class="text-right">
|
||||
@if ($rowPayout && $rowPayout['total'] > 0)
|
||||
{{ \App\Services\Incentive\IncentivePayoutService::formatAmount($rowPayout['total']) }}
|
||||
@else
|
||||
—
|
||||
@endif
|
||||
</td>
|
||||
@endif
|
||||
<td class="text-center">
|
||||
@if ($isWinner)
|
||||
<span class="badge-winner">{{ __('incentive.winner') }}</span>
|
||||
|
|
|
|||
|
|
@ -515,25 +515,54 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Mindestqualifikation --}}
|
||||
<div class="col-md-4 mb-3 mb-md-0">
|
||||
<div class="card info-tile h-100 shadow-sm">
|
||||
<div class="card-body text-center py-4">
|
||||
<div class="tile-icon tile-icon-plane">
|
||||
<i class="ion ion-md-airplane"></i>
|
||||
</div>
|
||||
<h5 class="font-weight-bold mb-3">{{ __('incentive.section_min_qual') }}</h5>
|
||||
<div class="mb-3">
|
||||
<div class="tile-value">{{ $incentive->min_direct_partners }}</div>
|
||||
<span class="tile-label">{{ __('incentive.min_partners_label') }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="tile-value">{{ $incentive->min_customer_abos }}</div>
|
||||
<span class="tile-label">{{ __('incentive.min_abos_label') }}</span>
|
||||
@if ($incentive->hasCashPayout() && !$incentive->hasMinimumRequirements())
|
||||
{{-- Praemie (Recruiting-Challenge) --}}
|
||||
<div class="col-md-4 mb-3 mb-md-0">
|
||||
<div class="card info-tile h-100 shadow-sm" id="incentive-payout-tile">
|
||||
<div class="card-body text-center py-4">
|
||||
<div class="tile-icon tile-icon-plane">
|
||||
<i class="ion ion-md-cash"></i>
|
||||
</div>
|
||||
<h5 class="font-weight-bold mb-3">{{ __('incentive.payout_tile_title') }}</h5>
|
||||
@if ((float) $incentive->amount_per_partner > 0)
|
||||
<div class="mb-3">
|
||||
<div class="tile-value">{{ \App\Services\Incentive\IncentivePayoutService::formatAmount((float) $incentive->amount_per_partner) }}</div>
|
||||
<span class="tile-label">{{ __('incentive.payout_per_partner_label') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
@if ($incentive->rankBonuses() !== [])
|
||||
<div>
|
||||
<span class="tile-label d-block mb-1">{{ __('incentive.payout_top_bonus_title', ['n' => count($incentive->rankBonuses())]) }}</span>
|
||||
@foreach ($incentive->rankBonuses() as $bonusRank => $bonusAmount)
|
||||
<div><strong>{{ __('incentive.payout_rank_label', ['n' => $bonusRank]) }}:</strong>
|
||||
{{ \App\Services\Incentive\IncentivePayoutService::formatAmount($bonusAmount) }}</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
{{-- Mindestqualifikation --}}
|
||||
<div class="col-md-4 mb-3 mb-md-0">
|
||||
<div class="card info-tile h-100 shadow-sm">
|
||||
<div class="card-body text-center py-4">
|
||||
<div class="tile-icon tile-icon-plane">
|
||||
<i class="ion ion-md-airplane"></i>
|
||||
</div>
|
||||
<h5 class="font-weight-bold mb-3">{{ __('incentive.section_min_qual') }}</h5>
|
||||
<div class="mb-3">
|
||||
<div class="tile-value">{{ $incentive->min_direct_partners }}</div>
|
||||
<span class="tile-label">{{ __('incentive.min_partners_label') }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="tile-value">{{ $incentive->min_customer_abos }}</div>
|
||||
<span class="tile-label">{{ __('incentive.min_abos_label') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Punkte-System --}}
|
||||
<div class="col-md-4">
|
||||
|
|
@ -550,13 +579,15 @@
|
|||
</span>
|
||||
<span class="tile-label d-block">{{ __('incentive.points_partners_title') }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="tile-value" style="color: #9a9a00;">
|
||||
{{ number_format($incentive->points_abo_onetime, 0, ',', '.') }}
|
||||
<span class="small font-weight-normal">{{ __('incentive.points_short') }}</span>
|
||||
</span>
|
||||
<span class="tile-label d-block">{{ __('incentive.points_abos_title') }}</span>
|
||||
</div>
|
||||
@if ($incentive->usesAbos())
|
||||
<div>
|
||||
<span class="tile-value" style="color: #9a9a00;">
|
||||
{{ number_format($incentive->points_abo_onetime, 0, ',', '.') }}
|
||||
<span class="small font-weight-normal">{{ __('incentive.points_short') }}</span>
|
||||
</span>
|
||||
<span class="tile-label d-block">{{ __('incentive.points_abos_title') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -570,7 +601,7 @@
|
|||
{{ __('incentive.section_points') }}
|
||||
</h4>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3 mb-md-0">
|
||||
<div class="{{ $incentive->usesAbos() ? 'col-md-6 mb-3 mb-md-0' : 'col-md-12' }}">
|
||||
<div class="point-card point-card-partner">
|
||||
<h6 class="font-weight-bold mb-2">
|
||||
<i class="ion ion-md-person-add mr-1"></i>
|
||||
|
|
@ -583,10 +614,14 @@
|
|||
<ul>
|
||||
<li>{{ __('incentive.teaser_partner_onetime_text') }}</li>
|
||||
<li>{{ __('incentive.points_starter_package_label') }}</li>
|
||||
<li>{{ __('incentive.points_partner_boost') }}</li>
|
||||
@if ($incentive->includesSalesPoints())
|
||||
<li>{{ __('incentive.points_partner_boost') }}</li>
|
||||
@endif
|
||||
@include('user.incentive._payout_bullets', ['incentive' => $incentive])
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@if ($incentive->usesAbos())
|
||||
<div class="col-md-6">
|
||||
<div class="point-card point-card-abo">
|
||||
<h6 class="font-weight-bold mb-2">
|
||||
|
|
@ -600,10 +635,13 @@
|
|||
<ul>
|
||||
<li>{{ __('incentive.teaser_abo_onetime_text') }}</li>
|
||||
<li>{{ __('incentive.points_abo_direct') }}</li>
|
||||
<li>{{ __('incentive.points_abo_boost') }}</li>
|
||||
@if ($incentive->includesSalesPoints())
|
||||
<li>{{ __('incentive.points_abo_boost') }}</li>
|
||||
@endif
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -394,6 +394,7 @@ Route::domain(config('app.pre_url_crm').config('app.domain').config('app.tld_car
|
|||
Route::get('/admin/incentives/{id}/edit', 'Admin\IncentiveController@edit')->name('admin_incentive_edit');
|
||||
Route::post('/admin/incentives/{id}/update', 'Admin\IncentiveController@update')->name('admin_incentive_update');
|
||||
Route::post('/admin/incentives/{id}/recalculate', 'Admin\IncentiveController@recalculate')->name('admin_incentive_recalculate');
|
||||
Route::get('/admin/incentives/{id}/payout-export', 'Admin\IncentiveController@payoutExport')->name('admin_incentive_payout_export');
|
||||
Route::get('/admin/incentives/participant/{participant_id}/details', 'Admin\IncentiveController@participantDetails')->name('admin_incentive_participant_details');
|
||||
|
||||
// payments invoice // Rechnungen
|
||||
|
|
|
|||
147
tests/Feature/Incentive/IncentiveAdminPayoutTest.php
Normal file
147
tests/Feature/Incentive/IncentiveAdminPayoutTest.php
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Incentive;
|
||||
use App\Models\IncentiveParticipant;
|
||||
use App\Models\UserAccount;
|
||||
use App\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
/**
|
||||
* E1: Admin legt die Recruiting-Challenge mit Praemienfeldern an und laedt die
|
||||
* Auszahlungsliste als XLSX. Admin-Routen sind host-abhaengig (CRM-Host).
|
||||
*/
|
||||
beforeEach(function () {
|
||||
$this->previousHttpHost = $_SERVER['HTTP_HOST'] ?? null;
|
||||
$_SERVER['HTTP_HOST'] = config('domains.domains.crm.host');
|
||||
$this->refreshApplication();
|
||||
$this->artisan('migrate');
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
if ($this->previousHttpHost === null) {
|
||||
unset($_SERVER['HTTP_HOST']);
|
||||
} else {
|
||||
$_SERVER['HTTP_HOST'] = $this->previousHttpHost;
|
||||
}
|
||||
});
|
||||
|
||||
function payoutAdmin(): User
|
||||
{
|
||||
$account = UserAccount::create(['first_name' => 'Ad', 'last_name' => 'Min']);
|
||||
|
||||
return User::forceCreate([
|
||||
'email' => 'admin-'.uniqid('', true).'@example.com',
|
||||
'password' => bcrypt('secret'),
|
||||
'lang' => 'de',
|
||||
'admin' => 2,
|
||||
'account_id' => $account->id,
|
||||
'payment_account' => now()->addYear(),
|
||||
]);
|
||||
}
|
||||
|
||||
function challengePayload(array $overrides = []): array
|
||||
{
|
||||
return array_merge([
|
||||
'name' => 'Recruiting-Challenge Okt-Jan',
|
||||
'qualification_start' => '01.10.2026',
|
||||
'qualification_end' => '31.01.2027',
|
||||
'calculation_end' => '28.02.2027',
|
||||
'points_partner_onetime' => 1,
|
||||
'points_abo_onetime' => 0,
|
||||
'min_direct_partners' => 0,
|
||||
'min_customer_abos' => 0,
|
||||
'max_winners' => 3,
|
||||
'amount_per_partner' => '50',
|
||||
'bonus_rank_1' => '300',
|
||||
'bonus_rank_2' => '200',
|
||||
'bonus_rank_3' => '',
|
||||
'include_sales_points' => '0',
|
||||
'status' => 1,
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
it('speichert Praemienfelder und den Umsatzpunkte-Schalter beim Anlegen', function () {
|
||||
$this->actingAs(payoutAdmin());
|
||||
|
||||
$this->post(route('admin_incentive_store'), challengePayload())->assertRedirect(route('admin_incentives'));
|
||||
|
||||
$incentive = Incentive::where('name', 'Recruiting-Challenge Okt-Jan')->firstOrFail();
|
||||
|
||||
expect((float) $incentive->amount_per_partner)->toBe(50.0)
|
||||
->and($incentive->rankBonuses())->toBe([1 => 300.0, 2 => 200.0])
|
||||
->and($incentive->bonus_rank_3)->toBeNull()
|
||||
->and($incentive->includesSalesPoints())->toBeFalse()
|
||||
->and($incentive->hasCashPayout())->toBeTrue();
|
||||
});
|
||||
|
||||
it('setzt leere Betragsfelder beim Bearbeiten auf NULL und den Schalter wieder an', function () {
|
||||
$this->actingAs(payoutAdmin());
|
||||
$incentive = Incentive::factory()->create([
|
||||
'amount_per_partner' => 50, 'bonus_rank_1' => 300, 'include_sales_points' => false,
|
||||
]);
|
||||
|
||||
$this->post(route('admin_incentive_update', [$incentive->id]), challengePayload([
|
||||
'amount_per_partner' => '', 'bonus_rank_1' => '', 'bonus_rank_2' => '', 'include_sales_points' => '1',
|
||||
]))->assertRedirect(route('admin_incentive_show', [$incentive->id]));
|
||||
|
||||
$incentive->refresh();
|
||||
|
||||
expect($incentive->amount_per_partner)->toBeNull()
|
||||
->and($incentive->hasCashPayout())->toBeFalse()
|
||||
->and($incentive->includesSalesPoints())->toBeTrue();
|
||||
});
|
||||
|
||||
it('lehnt negative Betraege ab', function () {
|
||||
$this->actingAs(payoutAdmin());
|
||||
|
||||
$this->from(route('admin_incentive_create'))
|
||||
->post(route('admin_incentive_store'), challengePayload(['amount_per_partner' => '-5']))
|
||||
->assertSessionHasErrors('amount_per_partner');
|
||||
});
|
||||
|
||||
it('laedt die Auszahlungsliste als XLSX herunter', function () {
|
||||
Excel::fake();
|
||||
$this->actingAs(payoutAdmin());
|
||||
|
||||
$incentive = Incentive::factory()->create([
|
||||
'slug' => 'recruiting-challenge',
|
||||
'amount_per_partner' => 50, 'bonus_rank_1' => 300, 'max_winners' => 3, 'include_sales_points' => false,
|
||||
]);
|
||||
$account = UserAccount::create(['first_name' => 'Anna', 'last_name' => 'Berater']);
|
||||
$winner = User::forceCreate([
|
||||
'email' => 'winner-'.uniqid('', true).'@example.com', 'password' => bcrypt('secret'), 'lang' => 'de', 'account_id' => $account->id,
|
||||
]);
|
||||
IncentiveParticipant::factory()->create([
|
||||
'incentive_id' => $incentive->id, 'user_id' => $winner->id,
|
||||
'qualified_partners' => 3, 'total_points' => 3, 'is_qualified' => true, 'rank' => 1,
|
||||
]);
|
||||
|
||||
$this->get(route('admin_incentive_payout_export', [$incentive->id]))->assertOk();
|
||||
|
||||
Excel::assertDownloaded('incentive-recruiting-challenge-auszahlung-'.now()->format('Y-m-d').'.xlsx', function ($export) {
|
||||
$rows = $export->collection();
|
||||
|
||||
return $rows->count() === 1
|
||||
&& $rows[0][2] === 'Anna Berater'
|
||||
&& $rows[0][9] === 450.0;
|
||||
});
|
||||
});
|
||||
|
||||
it('zeigt auf der Admin-Detailseite Export-Button und Praemienspalte nur bei Geldpraemie', function () {
|
||||
$this->actingAs(payoutAdmin());
|
||||
|
||||
$challenge = Incentive::factory()->create(['amount_per_partner' => 50, 'include_sales_points' => false]);
|
||||
$montenegro = Incentive::factory()->create();
|
||||
|
||||
$this->get(route('admin_incentive_show', [$challenge->id]))
|
||||
->assertOk()
|
||||
->assertSee('id="incentive-payout-export"', false)
|
||||
->assertSee(__('incentive.payout_total'));
|
||||
|
||||
$this->get(route('admin_incentive_show', [$montenegro->id]))
|
||||
->assertOk()
|
||||
->assertDontSee('id="incentive-payout-export"', false);
|
||||
});
|
||||
207
tests/Feature/Incentive/IncentivePayoutTest.php
Normal file
207
tests/Feature/Incentive/IncentivePayoutTest.php
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Incentive;
|
||||
use App\Models\IncentiveParticipant;
|
||||
use App\Models\UserAccount;
|
||||
use App\Services\Incentive\IncentivePayoutService;
|
||||
use App\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
/**
|
||||
* Recruiting-Challenge (Entwicklungsplan September 2026, E1): Geldpraemie je Neupartner
|
||||
* plus Rang-Bonus fuer die Gewinnerplaetze, mehrere parallel aktive Incentives.
|
||||
*/
|
||||
function payoutUser(string $firstName = 'Anna'): User
|
||||
{
|
||||
$account = UserAccount::create(['first_name' => $firstName, 'last_name' => 'Berater']);
|
||||
|
||||
return User::forceCreate([
|
||||
'email' => 'payout-'.uniqid('', true).'@example.com',
|
||||
'password' => bcrypt('secret'),
|
||||
'lang' => 'de',
|
||||
'account_id' => $account->id,
|
||||
'payment_account' => now()->addYear(),
|
||||
]);
|
||||
}
|
||||
|
||||
function recruitingChallenge(array $overrides = []): Incentive
|
||||
{
|
||||
return Incentive::factory()->create(array_merge([
|
||||
'name' => 'Recruiting-Challenge',
|
||||
'qualification_start' => '2026-10-01',
|
||||
'qualification_end' => '2027-01-31',
|
||||
'calculation_end' => '2027-02-28',
|
||||
'points_partner_onetime' => 1,
|
||||
'points_abo_onetime' => 0,
|
||||
'min_direct_partners' => 0,
|
||||
'min_customer_abos' => 0,
|
||||
'max_winners' => 3,
|
||||
'amount_per_partner' => 50,
|
||||
'bonus_rank_1' => 300,
|
||||
'bonus_rank_2' => 200,
|
||||
'bonus_rank_3' => 100,
|
||||
'include_sales_points' => false,
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
describe('Incentive-Modell: Praemienfelder', function () {
|
||||
it('erkennt eine Geldpraemie und die Challenge-Wertung nach Neupartnern', function () {
|
||||
$challenge = recruitingChallenge();
|
||||
$montenegro = Incentive::factory()->create();
|
||||
|
||||
expect($challenge->hasCashPayout())->toBeTrue()
|
||||
->and($challenge->rankBonuses())->toBe([1 => 300.0, 2 => 200.0, 3 => 100.0])
|
||||
->and($challenge->includesSalesPoints())->toBeFalse()
|
||||
->and($challenge->hasMinimumRequirements())->toBeFalse()
|
||||
->and($challenge->usesAbos())->toBeFalse()
|
||||
->and($challenge->ranksByPartnerCount())->toBeTrue()
|
||||
->and($montenegro->hasCashPayout())->toBeFalse()
|
||||
->and($montenegro->includesSalesPoints())->toBeTrue()
|
||||
->and($montenegro->ranksByPartnerCount())->toBeFalse();
|
||||
});
|
||||
|
||||
it('gibt den Rang-Bonus nur innerhalb der Gewinnerplaetze', function () {
|
||||
$challenge = recruitingChallenge(['max_winners' => 2]);
|
||||
|
||||
expect($challenge->rankBonus(1))->toBe(300.0)
|
||||
->and($challenge->rankBonus(2))->toBe(200.0)
|
||||
->and($challenge->rankBonus(3))->toBe(0.0)
|
||||
->and($challenge->rankBonus(null))->toBe(0.0);
|
||||
});
|
||||
|
||||
it('liefert aktive Incentives fuer die Navigation in Reihenfolge des Starts', function () {
|
||||
$later = recruitingChallenge();
|
||||
$earlier = Incentive::factory()->create(['qualification_start' => '2026-04-01']);
|
||||
Incentive::factory()->closed()->create();
|
||||
Incentive::factory()->draft()->create();
|
||||
|
||||
expect(Incentive::activeForNavigation()->pluck('id')->all())->toBe([$earlier->id, $later->id]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('IncentivePayoutService', function () {
|
||||
it('rechnet Neupartner mal Betrag plus Rang-Bonus fuer qualifizierte Gewinner', function () {
|
||||
$challenge = recruitingChallenge();
|
||||
$service = new IncentivePayoutService;
|
||||
|
||||
$first = IncentiveParticipant::factory()->create([
|
||||
'incentive_id' => $challenge->id, 'user_id' => payoutUser()->id,
|
||||
'qualified_partners' => 4, 'total_points' => 4, 'is_qualified' => true, 'rank' => 1,
|
||||
]);
|
||||
$fourth = IncentiveParticipant::factory()->create([
|
||||
'incentive_id' => $challenge->id, 'user_id' => payoutUser()->id,
|
||||
'qualified_partners' => 1, 'total_points' => 1, 'is_qualified' => true, 'rank' => 4,
|
||||
]);
|
||||
$notQualified = IncentiveParticipant::factory()->create([
|
||||
'incentive_id' => $challenge->id, 'user_id' => payoutUser()->id,
|
||||
'qualified_partners' => 2, 'total_points' => 2, 'is_qualified' => false, 'rank' => 2,
|
||||
]);
|
||||
|
||||
expect($service->forParticipant($challenge, $first))->toBe([
|
||||
'partners' => 4, 'per_partner' => 50.0, 'partner_amount' => 200.0, 'rank_bonus' => 300.0, 'total' => 500.0,
|
||||
])->and($service->forParticipant($challenge, $fourth)['total'])->toBe(50.0)
|
||||
->and($service->forParticipant($challenge, $notQualified))->toMatchArray(['rank_bonus' => 0.0, 'total' => 100.0]);
|
||||
});
|
||||
|
||||
it('listet nur Teilnehmer mit Praemie, nach Rang, und summiert', function () {
|
||||
$challenge = recruitingChallenge();
|
||||
$service = new IncentivePayoutService;
|
||||
|
||||
$second = IncentiveParticipant::factory()->create([
|
||||
'incentive_id' => $challenge->id, 'user_id' => payoutUser('Bea')->id,
|
||||
'qualified_partners' => 2, 'total_points' => 2, 'is_qualified' => true, 'rank' => 2,
|
||||
]);
|
||||
$first = IncentiveParticipant::factory()->create([
|
||||
'incentive_id' => $challenge->id, 'user_id' => payoutUser('Anna')->id,
|
||||
'qualified_partners' => 3, 'total_points' => 3, 'is_qualified' => true, 'rank' => 1,
|
||||
]);
|
||||
IncentiveParticipant::factory()->create([
|
||||
'incentive_id' => $challenge->id, 'user_id' => payoutUser('Null')->id,
|
||||
'qualified_partners' => 0, 'total_points' => 0, 'is_qualified' => true, 'rank' => null,
|
||||
]);
|
||||
|
||||
$rows = $service->rows($challenge);
|
||||
|
||||
expect($rows->pluck('participant.id')->all())->toBe([$first->id, $second->id])
|
||||
->and($service->totals($rows))->toBe([
|
||||
'recipients' => 2, 'partners' => 5, 'partner_amount' => 250.0, 'rank_bonus' => 500.0, 'total' => 750.0,
|
||||
]);
|
||||
|
||||
$export = $service->exportRows($challenge);
|
||||
expect($export)->toHaveCount(2)
|
||||
->and($export[0])->toHaveCount(count($service->exportHeaders()))
|
||||
->and($export[0][0])->toBe(1)
|
||||
->and($export[0][1])->toBe($first->user_id)
|
||||
->and($export[0][2])->toBe('Anna Berater')
|
||||
->and($export[0][5])->toBe(3)
|
||||
->and($export[0][6])->toBe(50.0)
|
||||
->and($export[0][7])->toBe(150.0)
|
||||
->and($export[0][8])->toBe(300.0)
|
||||
->and($export[0][9])->toBe(450.0);
|
||||
});
|
||||
|
||||
it('zahlt ohne Betrag je Neupartner nur den Rang-Bonus', function () {
|
||||
$challenge = recruitingChallenge(['amount_per_partner' => null]);
|
||||
$participant = IncentiveParticipant::factory()->create([
|
||||
'incentive_id' => $challenge->id, 'user_id' => payoutUser()->id,
|
||||
'qualified_partners' => 5, 'total_points' => 5, 'is_qualified' => true, 'rank' => 3,
|
||||
]);
|
||||
|
||||
expect($challenge->hasCashPayout())->toBeTrue()
|
||||
->and((new IncentivePayoutService)->forParticipant($challenge, $participant))->toMatchArray([
|
||||
'partner_amount' => 0.0, 'rank_bonus' => 100.0, 'total' => 100.0,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ansichten', function () {
|
||||
beforeEach(function () {
|
||||
URL::resolveMissingNamedRoutesUsing(fn (string $name, array $parameters) => '/'.$name.'/'.implode('/', array_map('strval', $parameters)));
|
||||
});
|
||||
|
||||
it('zeigt die Praemien-Kachel mit Betrag und Boni', function () {
|
||||
$html = view('user.incentive._payout_tile', ['incentive' => recruitingChallenge()])->render();
|
||||
|
||||
expect($html)->toContain('id="incentive-payout-tile"')
|
||||
->toContain('50,00 €')
|
||||
->toContain(__('incentive.payout_rank_bonus_label', ['n' => 1]))
|
||||
->toContain('300,00 €');
|
||||
});
|
||||
|
||||
it('rendert im Dashboard je aktivem Incentive eine Karte mit eigenen Modal-IDs', function () {
|
||||
$montenegro = Incentive::factory()->create(['name' => 'Montenegro', 'qualification_start' => '2026-04-01']);
|
||||
$challenge = recruitingChallenge();
|
||||
$user = payoutUser();
|
||||
// Noch unbestaetigt: dann erscheint auch fuer die Challenge das Teilnahme-Modal.
|
||||
$participant = IncentiveParticipant::factory()->unconfirmed()->create([
|
||||
'incentive_id' => $challenge->id, 'user_id' => $user->id,
|
||||
'qualified_partners' => 2, 'total_points' => 2, 'is_qualified' => true, 'rank' => 1,
|
||||
]);
|
||||
|
||||
$html = view('dashboard._incentive', [
|
||||
'activeIncentives' => Incentive::activeForNavigation(),
|
||||
'incentiveParticipants' => collect([$challenge->id => $participant]),
|
||||
])->render();
|
||||
|
||||
expect($html)->toContain('Montenegro')
|
||||
->toContain('Recruiting-Challenge')
|
||||
->toContain('id="incParticipateModal-'.$montenegro->id.'"')
|
||||
->toContain('id="incParticipateModal-'.$challenge->id.'"')
|
||||
->toContain(__('incentive.badge_challenge'))
|
||||
->toContain(__('incentive.payout_so_far'))
|
||||
->toContain('400,00 €')
|
||||
->and(substr_count($html, '<style>'))->toBe(1);
|
||||
});
|
||||
|
||||
it('rendert nichts, wenn kein Incentive aktiv ist', function () {
|
||||
$html = view('dashboard._incentive', [
|
||||
'activeIncentives' => collect(),
|
||||
'incentiveParticipants' => collect(),
|
||||
])->render();
|
||||
|
||||
expect(trim($html))->toBe('');
|
||||
});
|
||||
});
|
||||
135
tests/Feature/Incentive/IncentiveSalesPointsToggleTest.php
Normal file
135
tests/Feature/Incentive/IncentiveSalesPointsToggleTest.php
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Incentive;
|
||||
use App\Models\IncentiveNewPartner;
|
||||
use App\Models\IncentiveParticipant;
|
||||
use App\Models\IncentivePointsLog;
|
||||
use App\Models\UserSalesVolume;
|
||||
use App\Services\Incentive\IncentivePointsLogRepairService;
|
||||
use App\Services\Incentive\IncentiveTracker;
|
||||
use App\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
/**
|
||||
* E1: Bei `include_sales_points = 0` (Recruiting-Challenge) zaehlen nur die Einmalpunkte
|
||||
* je Neupartner; Umsaetze der Neupartner erzeugen keine Punkte.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
Carbon::setTestNow(Carbon::parse('2026-11-15 12:00:00'));
|
||||
});
|
||||
|
||||
afterEach(fn () => Carbon::setTestNow());
|
||||
|
||||
function toggleUser(array $overrides = []): User
|
||||
{
|
||||
return User::forceCreate(array_merge([
|
||||
'email' => 'toggle-'.uniqid('', true).'@example.com',
|
||||
'password' => bcrypt('secret'),
|
||||
'lang' => 'de',
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: Incentive, 1: IncentiveParticipant, 2: User}
|
||||
*/
|
||||
function trackedNewPartner(bool $includeSalesPoints): array
|
||||
{
|
||||
$incentive = Incentive::factory()->create([
|
||||
'qualification_start' => '2026-10-01',
|
||||
'qualification_end' => '2027-01-31',
|
||||
'calculation_end' => '2027-02-28',
|
||||
'points_partner_onetime' => 1,
|
||||
'points_abo_onetime' => 0,
|
||||
'min_direct_partners' => 0,
|
||||
'min_customer_abos' => 0,
|
||||
'max_winners' => 3,
|
||||
'include_sales_points' => $includeSalesPoints,
|
||||
]);
|
||||
$sponsor = toggleUser();
|
||||
$partner = toggleUser(['m_sponsor' => $sponsor->id]);
|
||||
$participant = IncentiveParticipant::factory()->create([
|
||||
'incentive_id' => $incentive->id,
|
||||
'user_id' => $sponsor->id,
|
||||
]);
|
||||
$tracking = IncentiveNewPartner::create([
|
||||
'participant_id' => $participant->id,
|
||||
'user_id' => $partner->id,
|
||||
'registered_at' => '2026-10-20 10:00:00',
|
||||
]);
|
||||
IncentivePointsLog::create([
|
||||
'participant_id' => $participant->id,
|
||||
'type' => 'partner',
|
||||
'source_type' => User::class,
|
||||
'source_id' => $partner->id,
|
||||
'source_label' => 'Neupartner',
|
||||
'month' => 10,
|
||||
'year' => 2026,
|
||||
'points_onetime' => 1,
|
||||
'points_accumulated' => 0,
|
||||
'incentive_new_partner_id' => $tracking->id,
|
||||
]);
|
||||
$participant->recalculateFromTrackingTables()->save();
|
||||
|
||||
return [$incentive, $participant, $partner];
|
||||
}
|
||||
|
||||
function partnerSalesVolume(User $partner, int $points = 120): UserSalesVolume
|
||||
{
|
||||
return UserSalesVolume::create([
|
||||
'user_id' => $partner->id,
|
||||
'month' => 11,
|
||||
'year' => 2026,
|
||||
'date' => '15.11.2026',
|
||||
'points' => $points,
|
||||
'total_net' => 150,
|
||||
'status_points' => 1,
|
||||
'status' => 1,
|
||||
'message' => 'Eigenbestellung Neupartner',
|
||||
]);
|
||||
}
|
||||
|
||||
it('schreibt ohne Umsatzpunkte keinen Log fuer Umsaetze des Neupartners', function () {
|
||||
[$incentive, $participant, $partner] = trackedNewPartner(includeSalesPoints: false);
|
||||
|
||||
IncentiveTracker::trackSalesVolume(partnerSalesVolume($partner));
|
||||
|
||||
expect(IncentivePointsLog::where('participant_id', $participant->id)->whereNotNull('user_sales_volume_id')->count())->toBe(0)
|
||||
->and($participant->fresh()->total_points)->toBe(1)
|
||||
->and($participant->fresh()->qualified_partners)->toBe(1);
|
||||
});
|
||||
|
||||
it('schreibt mit Umsatzpunkten weiterhin den Log (Montenegro-Verhalten)', function () {
|
||||
[$incentive, $participant, $partner] = trackedNewPartner(includeSalesPoints: true);
|
||||
|
||||
IncentiveTracker::trackSalesVolume(partnerSalesVolume($partner));
|
||||
|
||||
expect(IncentivePointsLog::where('participant_id', $participant->id)->whereNotNull('user_sales_volume_id')->count())->toBe(1)
|
||||
->and($participant->fresh()->total_points)->toBe(121);
|
||||
});
|
||||
|
||||
it('zieht ohne Umsatzpunkte auch bei der Reparatur keine Umsatz-Logs nach', function () {
|
||||
[$incentive, $participant, $partner] = trackedNewPartner(includeSalesPoints: false);
|
||||
partnerSalesVolume($partner);
|
||||
|
||||
$synced = (new IncentivePointsLogRepairService)->syncMissingSalesVolumeLogs($participant->fresh());
|
||||
|
||||
expect($synced)->toBe(0)
|
||||
->and(IncentivePointsLog::where('participant_id', $participant->id)->whereNotNull('user_sales_volume_id')->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('haelt die Umsatzpunkte auch beim Umschalten heraus, wenn die Wertung neu berechnet wird', function () {
|
||||
[$incentive, $participant, $partner] = trackedNewPartner(includeSalesPoints: true);
|
||||
IncentiveTracker::trackSalesVolume(partnerSalesVolume($partner));
|
||||
expect($participant->fresh()->total_points)->toBe(121);
|
||||
|
||||
// Schalter nachtraeglich aus: die bestehenden Umsatz-Logs bleiben, bis --force neu aufbaut;
|
||||
// ein Neuaufbau ohne Umsatzpunkte endet nach A/B.
|
||||
$incentive->update(['include_sales_points' => false]);
|
||||
$rebuilt = $participant->fresh()->load('incentive')->rebuildFromSourceTables();
|
||||
|
||||
expect(IncentivePointsLog::where('participant_id', $participant->id)->whereNotNull('user_sales_volume_id')->count())->toBe(0)
|
||||
->and($rebuilt->total_points)->toBe(0);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue