mivita/app/Services/SponsorBuyingPointsRepairService.php
Kevin 867dc21b57 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

337 lines
15 KiB
PHP

<?php
namespace App\Services;
use App\Models\ShoppingOrder;
use App\Models\UserSalesVolume;
use App\Services\BusinessPlan\SalesPointsVolume;
use App\User;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
/**
* Nacht-Repair fuer Vertriebspartner-Punkte (VP) des Sponsors.
*
* Live werden die VP-Punkte nur im Zahlungs-Callback gebucht
* ({@see Payment::addSponsorBuyingPoints} via Payment::paymentStatusPaidAction),
* seit 2026-08 inklusive shopping_order_id. Laeuft dieser Pfad nicht durch
* (verspaetete PAYONE-Meldungen, Stripe-Sonderfaelle), fehlt die status=5-Zeile
* ("Registrierung") beim Sponsor dauerhaft, obwohl das Incentive-Tracking per
* incentive:calculate nachgezogen wird.
*
* Dieser Repair zieht fehlende VP-Zeilen idempotent nach. Gebucht wird immer in
* den Ziel-Monat (Entscheidung 2026-08: keine rueckwirkenden Buchungen in
* abgeschlossene Provisionsmonate); geprueft werden auch Bestellungen des
* VORmonats, damit Monatswechsel-Faelle (Bestellung 31., Zahlung/Callback im
* Folgemonat) nicht durchs Raster fallen.
*
* Duplikat-Erkennung: primaer ueber shopping_order_id (eindeutig, robust gegen
* Umbenennungen); fuer Alt-Zeilen ohne Order-Referenz zaehlend ueber den
* info-Text im Pruef-Fenster — eine Alt-Zeile "verbraucht" genau eine
* Bestellung, zwei gleiche Bestellungen werden also nicht mehr faelschlich als
* eine erkannt.
*/
class SponsorBuyingPointsRepairService
{
/**
* @return array{checked: int, skipped_existing: int, skipped_other_sponsor: array<int, array{order_id: int, buyer: string, current_sponsor_id: int, booked_sponsor_id: int}>, rebooked: array<int, array{order_id: int, sponsor_id: int, sponsor: string, buyer: string, product: string, points: int}>, created: array<int, array{order_id: int, sponsor_id: int, sponsor: string, buyer: string, product: string, points: int}>}
*/
public function repair(int $month, int $year, bool $dryRun = false): array
{
$start = Carbon::createFromDate($year, $month, 1)->startOfMonth();
$end = $start->copy()->endOfMonth();
$scanStart = $start->copy()->subMonth()->startOfMonth();
$orders = ShoppingOrder::query()
->whereNotNull('auth_user_id')
->where('paid', true)
->whereBetween('created_at', [$scanStart, $end])
->whereHas('shopping_order_items.product', function ($q) {
$q->where('sponsor_buying_points', true);
})
->with('shopping_order_items.product')
->orderBy('id')
->get();
$buyers = User::query()
->with(['account', 'user_sponsor.account'])
->whereIn('id', $orders->pluck('auth_user_id')->unique())
->get()
->keyBy('id');
// Kandidaten-Infos vorab (Format wie Payment::addSponsorBuyingPoints),
// damit Alt-Zeilen ohne Order-Referenz gebatcht geladen werden koennen.
$candidateInfos = $orders
->flatMap(function (ShoppingOrder $order) use ($buyers) {
$buyer = $buyers->get($order->auth_user_id);
return $buyer === null ? [] : $order->shopping_order_items
->filter(fn ($item) => $item->product?->sponsor_buying_points)
->map(fn ($item) => 'VP: '.$buyer->getFullName(false).' | '.$item->product->name);
})
->unique()
->values();
// Sponsor-UNABHAENGIG deduplizieren: liegt die Buchung (nach Umhaengung)
// bei einem anderen Sponsor, wird unterschieden — Firmenaccount hat nie
// Anspruch auf VP-Punkte (User-Fehler bei Registrierung ueber die
// Hauptwebseite, Kundenentscheid 2026-08-19) und wird automatisch auf den
// Berater umgebucht; Berater-zu-Berater bleibt manuelle Pruefung.
[$bookedOrderRows, $legacyInfoRows] = $this->existingBookings($orders->pluck('id'), $candidateInfos, $scanStart, $end);
$companyUserId = (int) config('mivita.company_user_id');
$created = [];
$rebooked = [];
$skippedExisting = 0;
$skippedOtherSponsor = [];
$affectedSponsorIds = [];
foreach ($orders as $order) {
$buyer = $buyers->get($order->auth_user_id);
$sponsor = $buyer?->user_sponsor;
if (! $buyer || ! $sponsor) {
continue;
}
foreach ($order->shopping_order_items as $item) {
$product = $item->product;
if (! $product || ! $product->sponsor_buying_points) {
continue;
}
$info = 'VP: '.$buyer->getFullName(false).' | '.$product->name;
$existingRow = $bookedOrderRows[$order->id] ?? null;
if ($existingRow === null && ($legacyInfoRows[$info] ?? []) !== []) {
// Alt-Zeile ohne Order-Referenz: deckt genau EINE Bestellung ab.
$existingRow = array_shift($legacyInfoRows[$info]);
}
if ($existingRow !== null) {
$existingSponsorId = (int) $existingRow['user_id'];
if ($existingSponsorId === (int) $sponsor->id) {
$skippedExisting++;
continue;
}
if ($existingSponsorId === $companyUserId && (int) $sponsor->id !== $companyUserId) {
$rebooked[] = [
'order_id' => $order->id,
'sponsor_id' => (int) $sponsor->id,
'sponsor' => $sponsor->getFullName(false),
'buyer' => $buyer->getFullName(false),
'product' => $product->name,
'points' => (int) $product->sponsor_buying_points_amount,
];
if (! $dryRun) {
$this->rebookFromCompany($existingRow, $order, $sponsor, $buyer, $product, $info, $month, $year, $affectedSponsorIds);
}
continue;
}
$skippedExisting++;
$skippedOtherSponsor[] = [
'order_id' => $order->id,
'buyer' => $buyer->getFullName(false),
'current_sponsor_id' => $sponsor->id,
'booked_sponsor_id' => $existingSponsorId,
];
Log::warning('SponsorBuyingPointsRepair: VP-Buchung liegt bei anderem Berater — manuell pruefen (Umhaengung?)', [
'shopping_order_id' => $order->id,
'buyer_id' => $buyer->id,
'current_sponsor_id' => $sponsor->id,
'booked_sponsor_id' => $existingSponsorId,
]);
continue;
}
$created[] = [
'order_id' => $order->id,
'sponsor_id' => $sponsor->id,
'sponsor' => $sponsor->getFullName(false),
'buyer' => $buyer->getFullName(false),
'product' => $product->name,
'points' => (int) $product->sponsor_buying_points_amount,
];
if ($dryRun) {
continue;
}
UserSalesVolume::create([
'user_id' => $sponsor->id,
'shopping_order_id' => $order->id,
'month' => $month,
'year' => $year,
'date' => now()->format('d.m.Y'),
'points' => (int) $product->sponsor_buying_points_amount,
'status_points' => 2,
'total_net' => 0,
'message' => 'hinzugefügt: '.now()->format('d.m.Y').' (Nacht-Repair)',
'info' => $info,
'syslog' => [now()->format('d.m.Y-h:i:s') => 'sales:repair-sponsor-vp order #'.$order->id],
'status' => 5,
]);
$bookedOrderRows[$order->id] = ['id' => 0, 'user_id' => (int) $sponsor->id, 'month' => $month, 'year' => $year];
$affectedSponsorIds[$sponsor->id] = true;
Log::info('SponsorBuyingPointsRepair: VP-Punkte nachgebucht', [
'shopping_order_id' => $order->id,
'sponsor_id' => $sponsor->id,
'buyer_id' => $buyer->id,
'points' => (int) $product->sponsor_buying_points_amount,
'month' => $month,
'year' => $year,
]);
}
}
foreach (array_keys($affectedSponsorIds) as $sponsorId) {
SalesPointsVolume::reCalculateSalesPointsVolume($sponsorId, $month, $year);
}
return [
'checked' => $orders->count(),
'skipped_existing' => $skippedExisting,
'skipped_other_sponsor' => $skippedOtherSponsor,
'rebooked' => $rebooked,
'created' => $created,
];
}
/**
* Umbuchung Firmenaccount → Berater (Kundenentscheid 2026-08-19: VP-Punkte
* stehen immer dem Berater zu; Landung beim Firmenaccount = User-Fehler bei
* der Registrierung ueber die Hauptwebseite).
*
* - Zeile im Ziel-Monat: in place umhaengen (eine Zeile, Syslog-Spur).
* - Zeile in einem abgeschlossenen Monat: der Monat bleibt unangetastet —
* neue Buchung beim Berater im Ziel-Monat plus Gegenbuchung (negative
* Punkte) beim Firmenaccount im Ziel-Monat, damit die Summen stimmen.
*
* @param array{id: int, user_id: int, month: int, year: int} $existingRow
* @param array<int, bool> $affectedSponsorIds
*/
private function rebookFromCompany(array $existingRow, ShoppingOrder $order, User $sponsor, User $buyer, $product, string $info, int $month, int $year, array &$affectedSponsorIds): void
{
$points = (int) $product->sponsor_buying_points_amount;
$isTargetMonth = (int) $existingRow['month'] === $month && (int) $existingRow['year'] === $year;
if ($isTargetMonth) {
$volume = UserSalesVolume::find($existingRow['id']);
if ($volume !== null) {
$syslog = $volume->syslog ?? [];
$syslog[now()->format('d.m.Y-h:i:s')] = 'sales:repair-sponsor-vp: Umbuchung Firmenaccount #'.$existingRow['user_id'].' -> Berater #'.$sponsor->id.' (order #'.$order->id.')';
$volume->user_id = $sponsor->id;
$volume->shopping_order_id = $order->id;
$volume->info = $info;
$volume->syslog = $syslog;
$volume->save();
}
} else {
UserSalesVolume::create([
'user_id' => $sponsor->id,
'shopping_order_id' => $order->id,
'month' => $month,
'year' => $year,
'date' => now()->format('d.m.Y'),
'points' => $points,
'status_points' => 2,
'total_net' => 0,
'message' => 'hinzugefügt: '.now()->format('d.m.Y').' (Umbuchung vom Firmenaccount)',
'info' => $info,
'syslog' => [now()->format('d.m.Y-h:i:s') => 'sales:repair-sponsor-vp order #'.$order->id.' Umbuchung von Firmenaccount #'.$existingRow['user_id'].' ('.$existingRow['month'].'/'.$existingRow['year'].')'],
'status' => 5,
]);
// Bewusst abweichendes info-Format ("VP-Storno"), damit die
// Gegenbuchung nie als Alt-Buchung einer Bestellung erkannt wird.
UserSalesVolume::create([
'user_id' => $existingRow['user_id'],
'shopping_order_id' => null,
'month' => $month,
'year' => $year,
'date' => now()->format('d.m.Y'),
'points' => -$points,
'status_points' => 2,
'total_net' => 0,
'message' => 'hinzugefügt: '.now()->format('d.m.Y').' (Gegenbuchung Umbuchung an Berater)',
'info' => 'VP-Storno: '.$buyer->getFullName(false).' | '.$product->name.' -> Berater #'.$sponsor->id,
'syslog' => [now()->format('d.m.Y-h:i:s') => 'sales:repair-sponsor-vp order #'.$order->id.' Gegenbuchung zu Zeile #'.$existingRow['id']],
'status' => 5,
]);
}
$affectedSponsorIds[$sponsor->id] = true;
$affectedSponsorIds[$existingRow['user_id']] = true;
Log::info('SponsorBuyingPointsRepair: VP-Punkte vom Firmenaccount auf Berater umgebucht', [
'shopping_order_id' => $order->id,
'buyer_id' => $buyer->id,
'company_user_id' => $existingRow['user_id'],
'sponsor_id' => $sponsor->id,
'points' => $points,
'in_place' => $isTargetMonth,
]);
}
/**
* Bestehende VP-Buchungen im Pruef-Fenster, einmal gebatcht und bewusst
* sponsor-unabhaengig (Umhaengungs-Faelle!): Order-Referenzen
* (monatsunabhaengig eindeutig) plus Alt-Zeilen ohne Referenz je info-Text —
* jede Alt-Zeile deckt genau EINE Bestellung ab (zaehlend, damit zwei
* gleiche Bestellungen nicht als eine erkannt werden).
*
* @param Collection<int, int> $orderIds
* @param Collection<int, string> $candidateInfos
* @return array{0: array<int, array{id: int, user_id: int, month: int, year: int}>, 1: array<string, array<int, array{id: int, user_id: int, month: int, year: int}>>}
*/
private function existingBookings(Collection $orderIds, Collection $candidateInfos, Carbon $scanStart, Carbon $end): array
{
if ($orderIds->isEmpty()) {
return [[], []];
}
$rows = UserSalesVolume::query()
->where('status', 5)
->where(function ($q) use ($orderIds, $candidateInfos, $scanStart, $end) {
$q->whereIn('shopping_order_id', $orderIds)
->orWhere(function ($qq) use ($candidateInfos, $scanStart, $end) {
$qq->whereNull('shopping_order_id')
->whereIn('info', $candidateInfos)
->where(function ($window) use ($scanStart, $end) {
$window->where(fn ($w) => $w->where('month', (int) $scanStart->month)->where('year', (int) $scanStart->year))
->orWhere(fn ($w) => $w->where('month', (int) $end->month)->where('year', (int) $end->year));
});
});
})
->get(['id', 'user_id', 'shopping_order_id', 'info', 'month', 'year']);
$bookedOrderRows = [];
$legacyInfoRows = [];
foreach ($rows as $row) {
$rowData = [
'id' => (int) $row->id,
'user_id' => (int) $row->user_id,
'month' => (int) $row->month,
'year' => (int) $row->year,
];
if ($row->shopping_order_id !== null) {
$bookedOrderRows[(int) $row->shopping_order_id] = $rowData;
} else {
$legacyInfoRows[$row->info][] = $rowData;
}
}
return [$bookedOrderRows, $legacyInfoRows];
}
}