mivita/app/Services/Incentive/IncentivePointsLogRepairService.php
Kevin d65922adfe Statistiken: Kundenfeedback 2026-08-19 komplett (N1-N8) + Review-Fixes + VP-/Incentive-Repair gehärtet
Statistik-Änderungen 2026-08-19 (dev/2026-07-22/entwicklungsplan-2026-08-19.md, alle 8 Pakete gemäß beantworteten F-Fragen):
- N1 Monatsstatistik: Team-Cluster (Firstlines klickbar -> neue Seite user_team_firstlines, Teampartner gesamt/Payline [nur aktive], Abos Payline mit +N) + Level-Kachel (Quali-Stufe, fehlende Payline-/persönliche Punkte, Karriere-Level); LiveCommissionService-Snapshot v3 reicht Level-/Quali-Daten durch
- N2 Abo-Kacheln auf Home monatsbezogen (aboStockCount-Rückrechnung über cancel/deleted/Status-Historie; VIP-Statistik bleibt tagesaktuell)
- N3 VIP-Statistik 'Recruiter des Monats' (Neupartner-Definition wie KPI, rekursives Ketten-Volumen als Gesamtpunkte inkl. Starterpaket) + Drilldown/CSV-Spalte 'Punkte gesamt'
- N4 Prognose-Block 'Level & Provision laut Prognose (Stand 25.)' mit unverbindlich-Kennzeichnung (ebenengenaue offene Abo-Punkte + TP-Laufstände)
- N5 Checkout-Herkunft: Dropdown raus, Pflicht-Freitext (lokalisierte Fehlermeldung)
- N6 Berater-Profilfoto (user_accounts.profile_image, Einwilligung als Sprachkey-Platzhalter, DSGVO-Löschung) + teaminterne Top-3-Karte (TopResultsService, Scope-Schalter)
- N7 USt-Optionen: 'nicht umsatzsteuerpflichtig' zuerst + '§ 19 UStG'-Textfix
- N8 Jahresgebühr-Nachfassliste je Berater (AnnualFeeDueService, überfällig/1-3 Monate, Sidenav)

Kontroll-Review (8 Perspektiven) angewendet: next-Level-Fallback im Steady-State (resolveNextLevel — sonst 'Höchstes Level erreicht' für Normalfälle), _calculated-Punktebasis bei mehr Paylines, keine Wiederbelebung deaktivierter Abos in der Monatsrückrechnung, Payline-Abo-Kachel monatskonsistent, Top-3-Nachrücker; Performance: BackofficeDashboardService als Singleton mit lineBuckets-Memo (+flushLineBucketsMemo), level_forecast 6h-Cache; Reuse: User-Scopes firstlinesOf/newPartnerIn, SalesVolumePoints::totalPointsByUser, Slim::storeCroppedImage (3 Upload-Kopien vereinigt), activeAbo-Scope statt Inline-Prädikate, Form::open-Konvention, Return-Types

Repair-Härtung (Kundenentscheide): VP-Nachtrepair dedupliziert über shopping_order_id (Live-Pfad schreibt sie jetzt mit), zählende Alt-Zeilen-Erkennung, Vormonats-Scan für Monatswechsel, Firmenaccount-Buchungen (config mivita.company_user_id) werden automatisch auf den Berater umgebucht (in place bzw. per Gegenbuchung bei geschlossenem Monat; Echtfall Anna Schall Order 58797 -> Christina Schuder), Berater-zu-Berater nur Warnung; Incentive-Repair: m_sponsor=NULL löscht nicht mehr, FK-Repair vor Stale-Bereinigung, gebatchte Lookups, zentrales Incentive::qualificationWindow(); .env aus dem Git-Tracking entfernt (Pflege per SFTP)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 10:44:44 +00:00

424 lines
16 KiB
PHP

<?php
namespace App\Services\Incentive;
use App\Models\IncentiveNewAbo;
use App\Models\IncentiveNewPartner;
use App\Models\IncentiveParticipant;
use App\Models\IncentivePointsLog;
use App\Models\ShoppingOrder;
use App\Models\UserAbo;
use App\Models\UserAboOrder;
use App\Models\UserSalesVolume;
use App\User;
use Illuminate\Support\Facades\Log;
class IncentivePointsLogRepairService
{
/**
* Fehlende Neupartner-Tracking-Zeilen anlegen (Starterpaket / gleiche Regeln wie Neuaufbau A).
* Nutzt IncentiveTracker::trackNewPartner, wenn eine qualifizierte Bestellung existiert.
*
* Neupartner = Starterpaket im Qualifikationszeitraum bezahlt (Bestelldatum, wie Live-Tracking).
* Das User-Registrierungsdatum ist bewusst irrelevant: auch Bestandskunden, die im Zeitraum
* zum Berater upgraden, zaehlen.
*
* @return int Anzahl nachgezogener Partner-Trackings fuer diesen Teilnehmer
*/
public function syncMissingTrackingPartners(IncentiveParticipant $participant): int
{
$incentive = $participant->incentive;
if (! $incentive) {
return 0;
}
$added = 0;
[$windowStart, $windowEnd] = $incentive->qualificationWindow();
$candidates = User::query()
->where('m_sponsor', $participant->user_id)
->whereHas('shopping_orders', function ($q) use ($windowStart, $windowEnd) {
$q->wherePaidRegistrationIncludesStarterKit()
->whereBetween('created_at', [$windowStart, $windowEnd]);
})
->get();
$firstStarterOrders = $candidates->isNotEmpty()
? ShoppingOrder::query()
->whereIn('auth_user_id', $candidates->pluck('id'))
->wherePaidRegistrationIncludesStarterKit()
->whereBetween('created_at', [$windowStart, $windowEnd])
->orderBy('id')
->get()
->groupBy('auth_user_id')
->map(fn ($orders) => $orders->first())
: collect();
foreach ($candidates as $partner) {
if (IncentiveNewPartner::query()
->where('participant_id', $participant->id)
->where('user_id', $partner->id)
->exists()) {
continue;
}
$order = $firstStarterOrders->get($partner->id);
if ($order) {
IncentiveTracker::trackNewPartner($order);
}
if (! IncentiveNewPartner::query()
->where('participant_id', $participant->id)
->where('user_id', $partner->id)
->exists()) {
$registeredAt = $order?->created_at ?? $partner->created_at;
$newPartner = IncentiveNewPartner::create([
'participant_id' => $participant->id,
'user_id' => $partner->id,
'registered_at' => $registeredAt,
]);
IncentivePointsLog::create([
'participant_id' => $participant->id,
'type' => 'partner',
'source_type' => User::class,
'source_id' => $partner->id,
'source_label' => $partner->getFullName() ?: $partner->email ?: ('User #'.$partner->id),
'month' => $registeredAt->month,
'year' => $registeredAt->year,
'points_onetime' => $incentive->points_partner_onetime,
'points_accumulated' => 0,
'incentive_new_partner_id' => $newPartner->id,
]);
}
if (IncentiveNewPartner::query()
->where('participant_id', $participant->id)
->where('user_id', $partner->id)
->exists()) {
$added++;
}
}
return $added;
}
/**
* Entfernt Neupartner-Trackings, deren Partner inzwischen einen anderen Sponsor hat
* (z. B. nach Umhaengen in der Teamstruktur). Die zugehoerigen Log-Zeilen (Einmalpunkte
* und akkumulierte Umsatzpunkte) werden mitgeloescht; beim aktuellen Sponsor legt
* {@see syncMissingTrackingPartners} + {@see syncMissingSalesVolumeLogs} im selben
* Lauf alles neu an. Geloeschte User bleiben unangetastet.
*
* Ein leerer Sponsor (m_sponsor = NULL, z. B. Admin-Umbau in Arbeit oder verwaister
* Sponsor entfernt) gilt NICHT als Sponsorwechsel: das Tracking bleibt erhalten,
* geloescht wird nur bei einem tatsaechlich gesetzten, abweichenden Sponsor.
*
* @return int Anzahl entfernter Partner-Trackings fuer diesen Teilnehmer
*/
public function removeStaleSponsorTrackings(IncentiveParticipant $participant): int
{
$removed = 0;
$trackings = $participant->newPartners()->get();
if ($trackings->isEmpty()) {
return 0;
}
$sponsorIdByUserId = User::query()
->whereIn('id', $trackings->pluck('user_id'))
->pluck('m_sponsor', 'id');
foreach ($trackings as $tracking) {
if (! $sponsorIdByUserId->has($tracking->user_id)) {
continue;
}
$currentSponsorId = $sponsorIdByUserId->get($tracking->user_id);
if ($currentSponsorId === null) {
Log::info('Incentive-Repair: Neupartner-Tracking uebersprungen, m_sponsor ist leer (kein Sponsorwechsel)', [
'participant_id' => $participant->id,
'tracking_id' => $tracking->id,
'partner_user_id' => $tracking->user_id,
'tracked_sponsor_user_id' => $participant->user_id,
]);
continue;
}
if ((int) $currentSponsorId === (int) $participant->user_id) {
continue;
}
Log::info('Incentive-Repair: Neupartner-Tracking wegen Sponsorwechsel entfernt', [
'participant_id' => $participant->id,
'tracking_id' => $tracking->id,
'partner_user_id' => $tracking->user_id,
'old_sponsor_user_id' => $participant->user_id,
'new_sponsor_user_id' => (int) $currentSponsorId,
]);
IncentivePointsLog::where('incentive_new_partner_id', $tracking->id)->delete();
$tracking->delete();
$removed++;
}
return $removed;
}
/**
* Fehlende Abo-Tracking-Zeilen anlegen (Kundenabo ot / Eigenabo me, wie IncentiveTracker).
* Zuerst trackAboActivated ueber Erstbestellung; ohne UserAboOrder manuell wie Neuaufbau B.
*
* @return int Anzahl nachgezogener Abo-Trackings fuer diesen Teilnehmer
*/
public function syncMissingTrackingAbos(IncentiveParticipant $participant): int
{
$incentive = $participant->incentive;
if (! $incentive) {
return 0;
}
$added = 0;
[$qualStart, $qualEnd] = $incentive->qualificationWindow();
$candidatesOt = UserAbo::query()
->where('is_for', 'ot')
->where('status', 2)
->where('member_id', $participant->user_id)
->whereBetween('created_at', [$qualStart, $qualEnd])
->get();
$candidatesMe = UserAbo::query()
->where('is_for', 'me')
->where('status', 2)
->where('user_id', $participant->user_id)
->where(function ($q) use ($qualStart, $qualEnd) {
$q->whereBetween('created_at', [$qualStart, $qualEnd])
->orWhere('created_at', '<', $qualStart);
})
->get();
foreach ($candidatesOt->concat($candidatesMe) as $userAbo) {
if (IncentiveNewAbo::query()
->where('participant_id', $participant->id)
->where('user_abo_id', $userAbo->id)
->exists()) {
continue;
}
$order = UserAboOrder::query()
->where('user_abo_id', $userAbo->id)
->orderBy('id')
->with('shopping_order')
->first();
$shoppingOrder = $order?->shopping_order;
if ($shoppingOrder) {
IncentiveTracker::trackAboActivated($shoppingOrder);
}
if (! IncentiveNewAbo::query()
->where('participant_id', $participant->id)
->where('user_abo_id', $userAbo->id)
->exists()) {
$activatedAt = $userAbo->created_at;
$logMonth = (int) $userAbo->created_at->month;
$logYear = (int) $userAbo->created_at->year;
if ($userAbo->is_for === 'me' && $userAbo->created_at->lt($qualStart)) {
$activatedAt = $qualStart->copy();
$logMonth = (int) $qualStart->month;
$logYear = (int) $qualStart->year;
}
$newAbo = IncentiveNewAbo::create([
'participant_id' => $participant->id,
'user_abo_id' => $userAbo->id,
'activated_at' => $activatedAt,
]);
IncentivePointsLog::create([
'participant_id' => $participant->id,
'type' => 'abo',
'source_type' => UserAbo::class,
'source_id' => $userAbo->id,
'source_label' => $userAbo->email ?: ('Abo #'.$userAbo->id),
'month' => $logMonth,
'year' => $logYear,
'points_onetime' => $incentive->points_abo_onetime,
'points_accumulated' => 0,
'incentive_new_abo_id' => $newAbo->id,
]);
}
if (IncentiveNewAbo::query()
->where('participant_id', $participant->id)
->where('user_abo_id', $userAbo->id)
->exists()) {
$added++;
}
}
return $added;
}
/**
* Setzt fehlende incentive_new_partner_id / incentive_new_abo_id an bestehenden Log-Zeilen.
*
* @return array{partner_fk: int, abo_fk: int, onetime_partner_fk: int, onetime_abo_fk: int}
*/
public function repairForeignKeys(IncentiveParticipant $participant): array
{
$stats = [
'partner_fk' => 0,
'abo_fk' => 0,
'onetime_partner_fk' => 0,
'onetime_abo_fk' => 0,
];
$newPartnerByUserId = $participant->newPartners()->get()->keyBy('user_id');
$newAboByUserAboId = $participant->newAbos()->get()->keyBy('user_abo_id');
foreach ($participant->pointsLog()->where('type', 'partner')->whereNull('incentive_new_partner_id')->cursor() as $log) {
if ($log->source_type === User::class && $log->source_id) {
$np = $newPartnerByUserId->get($log->source_id);
if ($np) {
$log->update(['incentive_new_partner_id' => $np->id]);
$stats['onetime_partner_fk']++;
}
continue;
}
if ($log->user_sales_volume_id) {
$sv = UserSalesVolume::find($log->user_sales_volume_id);
if ($sv && $sv->user_id) {
$np = $newPartnerByUserId->get($sv->user_id);
if ($np) {
$log->update(['incentive_new_partner_id' => $np->id]);
$stats['partner_fk']++;
}
}
}
}
$orderIdToUserAboId = [];
foreach ($participant->pointsLog()->where('type', 'abo')->whereNull('incentive_new_abo_id')->cursor() as $log) {
if ($log->source_type === UserAbo::class && $log->source_id) {
$na = $newAboByUserAboId->get($log->source_id);
if ($na) {
$log->update(['incentive_new_abo_id' => $na->id]);
$stats['onetime_abo_fk']++;
}
continue;
}
if ($log->user_sales_volume_id) {
$sv = UserSalesVolume::find($log->user_sales_volume_id);
if ($sv && $sv->shopping_order_id) {
if (! isset($orderIdToUserAboId[$sv->shopping_order_id])) {
$orderIdToUserAboId[$sv->shopping_order_id] = UserAboOrder::where('shopping_order_id', $sv->shopping_order_id)->value('user_abo_id');
}
$userAboId = $orderIdToUserAboId[$sv->shopping_order_id] ?? null;
if ($userAboId) {
$na = $newAboByUserAboId->get($userAboId);
if ($na) {
$log->update(['incentive_new_abo_id' => $na->id]);
$stats['abo_fk']++;
}
}
}
}
}
return $stats;
}
/**
* Ruft IncentiveTracker::trackSalesVolume fuer Kandidaten-USVs auf, bei denen noch kein Log-Eintrag fuer diesen Teilnehmer existiert.
*
* @return int Anzahl neu angelegter Log-Zeilen (geschaetzt ueber Vorher/Nachher pro Teilnehmer)
*/
public function syncMissingSalesVolumeLogs(IncentiveParticipant $participant): int
{
$incentive = $participant->incentive;
if (! $incentive) {
return 0;
}
$synced = 0;
$months = $incentive->getCalculationMonths();
$newPartnerUserIds = $participant->newPartners()->pluck('user_id')->filter()->values();
$trackedUserAboIds = $participant->newAbos()->pluck('user_abo_id')->filter()->values();
$orderIdsForAbos = $trackedUserAboIds->isNotEmpty()
? UserAboOrder::query()->whereIn('user_abo_id', $trackedUserAboIds)->pluck('shopping_order_id')->unique()->values()
: collect();
foreach ($months as $period) {
if ($newPartnerUserIds->isNotEmpty()) {
$svs = UserSalesVolume::query()
->whereIn('user_id', $newPartnerUserIds)
->where('month', $period['month'])
->where('year', $period['year'])
->where('status', '!=', 6)
->get();
foreach ($svs as $sv) {
if ((int) abs($sv->points ?? 0) <= 0) {
continue;
}
if ($this->participantHasSalesVolumeLog($participant, $sv->id)) {
continue;
}
IncentiveTracker::trackSalesVolume($sv);
if ($this->participantHasSalesVolumeLog($participant, $sv->id)) {
$synced++;
}
}
}
if ($orderIdsForAbos->isNotEmpty()) {
$svs = UserSalesVolume::query()
->whereIn('shopping_order_id', $orderIdsForAbos)
->where('month', $period['month'])
->where('year', $period['year'])
->where('status', '!=', 6)
->get();
foreach ($svs as $sv) {
if ((int) abs($sv->points ?? 0) <= 0) {
continue;
}
if ($this->participantHasSalesVolumeLog($participant, $sv->id)) {
continue;
}
IncentiveTracker::trackSalesVolume($sv);
if ($this->participantHasSalesVolumeLog($participant, $sv->id)) {
$synced++;
}
}
}
}
return $synced;
}
private function participantHasSalesVolumeLog(IncentiveParticipant $participant, int $userSalesVolumeId): bool
{
return IncentivePointsLog::query()
->where('participant_id', $participant->id)
->where('user_sales_volume_id', $userSalesVolumeId)
->where('is_storno', false)
->exists();
}
}