A01 — Abo-Regeln waren ueber das Aktiv-Feld umgehbar. Das Selbstbedienungsformular hat gar keinen Aktiv-Schalter, es spiegelt den Zustand nur in einem versteckten Feld zurueck; `updateStatus()` hat den Wert trotzdem uebernommen. Wer den Request manipulierte, konnte sein eigenes Abo dauerhaft deaktivieren und so die Mindestlaufzeit umgehen, am Pausenpfad mit seinen Sperren vorbei. Das Feld wirkt jetzt nur noch im Admin-Pfad. A16 — Statuswechsel wurden nur aus dem Formular protokolliert. Recovery-Zahlung, Retry, externe Erledigung und die DSGVO-Anonymisierung schreiben `active` direkt auf das Modell. Das ist heikel, weil die historische Bestandsrueckrechnung ein Abo verwirft, solange eine protokollierte Deaktivierung nicht durch eine spaetere protokollierte Reaktivierung aufgehoben ist — ein stiller `active = true` nimmt es dauerhaft aus allen Folgemonaten. Der Eintrag liegt deshalb jetzt am Model (`UserAbo::logStatusTransition()`) und nicht in den einzelnen Diensten: so kann ihn ein kuenftiger Schreibpfad nicht vergessen, und er entsteht in derselben Transaktion wie die Statusaenderung. Nebeneffekt: Die DSGVO-Anonymisierung setzt `active = false` ohne `cancel_date` — bisher fiel ein so abgeschaltetes Abo rueckwirkend aus allen vergangenen Monaten, jetzt bleibt die Historie korrekt. A13 — Ein heute faelliges Abo galt ab Mitternacht als erledigt, der Ausfuehrungsjob laeuft aber erst um 04:00. In diesem Fenster fehlten die Punkte in der Prognose UND im Ist. Der neue Scope `UserAbo::scopeOpenExecutionUntil()` zaehlt den heutigen Liefertag mit, solange keine Ausfuehrung angelegt ist — genau diese Bedingung verhindert die Doppelzaehlung. Alle drei Prognosen nutzen jetzt denselben Scope statt je einer eigenen Kopie der Datumsregel. A11 — Der Namens-Tiebreak der Top 3 lief nach dem Abschneiden auf 25 Kandidaten, bei Gleichstand entschied also die Reihenfolge der Abfrage. Die Auswahlgrenze wird jetzt um alle Gleichstaende an dieser Grenze erweitert, und Kandidaten ohne Account fallen vorher weg statt die Plaetze zu blockieren. Als letzte Regel entscheidet die user_id, damit eine gecachte Rangliste reproduzierbar bleibt. A14 — Sortiert wurde ueber den angezeigten Zellentext. Unter `en` liefert die Anwendung `Y-m-d` und `1,000.00`, damit ergab jedes Datum 0 und `1,000.00` wurde zur 1. Die Zellen tragen jetzt `data-sort-value` mit dem unformatierten Wert; ohne Attribut gilt weiter der Textweg. Dabei mitgenommen: Umlaute landeten hinter dem Z (jetzt localeCompare), leere Werte lagen als 0 zwischen den negativen Stornowerten, und `next_date` wurde doppelt formatiert. Ausserdem A08 (Prognose auch ohne erreichte Qualifikationsstufe), A09 (Summenzeile im Neupartner-CSV), A10 (Lieferungszahl vorgeladen), A17 (Faelligkeitsgruppen ohne Monatsueberlauf), A18 (Formelinhalte im CSV als Text) und A19 (fehlgeschlagene Provisionsberechnung ist als solche erkennbar statt 0,00 EUR). Audit-Befunde A01, A08, A09, A10, A11, A13, A14, A16, A17, A18, A19. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
267 lines
11 KiB
PHP
267 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Services\BusinessPlan;
|
|
|
|
use App\Models\UserBusiness;
|
|
use App\User;
|
|
use Illuminate\Support\Facades\Cache;
|
|
|
|
/**
|
|
* Gemeinsamer Live-Lauf für die "Stand heute"-Kennzahlen (Konzept B1/B8/B10):
|
|
* EIN TreeCalcBotOptimized-Lauf pro User/Monat liefert Payline-Punkte,
|
|
* Provisions-Bestandteile und die Payline-Tiefe, 6 h gecacht (F5).
|
|
*
|
|
* - Liegt für den Monat bereits ein UserBusiness-Datensatz vor (Monats-Batch
|
|
* `business:store-optimized` gelaufen), gelten dessen Werte als final.
|
|
* - Sonst rechnet TreeCalcBotOptimized live — dieselbe Engine wie der
|
|
* Monats-Batch (inkl. GrowthBonusCalculator und korrigierter Level-Auswahl),
|
|
* damit Live- und Finalwerte nicht systematisch auseinanderlaufen.
|
|
* - Fehlgeschlagene Läufe werden NICHT gecacht, damit ein transienter Fehler
|
|
* nicht 6 h lang Nullwerte anzeigt (`failed` markiert diesen Fall).
|
|
*/
|
|
class LiveCommissionService
|
|
{
|
|
private const CACHE_HOURS = 6;
|
|
|
|
/**
|
|
* @return array{payline_points: float, paylines: int, pp: float, shop: float, growth: float, is_live: bool, failed: bool, qual_level: array<string, mixed>|null, next_level: array<string, mixed>|null, payline_points_qual_kp: float, personal_points: float}
|
|
*/
|
|
public function snapshot(User $user, int $month, int $year): array
|
|
{
|
|
$stored = UserBusiness::where('user_id', $user->id)
|
|
->where('month', $month)
|
|
->where('year', $year)
|
|
->first();
|
|
|
|
if ($stored) {
|
|
return $this->buildSnapshot(
|
|
(float) ($stored->payline_points ?? 0),
|
|
$this->paylinesFromQualLevel($stored->qual_user_level),
|
|
(float) ($stored->commission_pp_total ?? 0),
|
|
(float) ($stored->commission_shop_sales ?? 0),
|
|
(float) ($stored->commission_growth_total ?? 0),
|
|
false,
|
|
qualLevel: $this->normalizeLevel($stored->qual_user_level),
|
|
nextLevel: $this->resolveNextLevel(
|
|
$stored->qual_user_level_next,
|
|
$stored->next_qual_user_level,
|
|
$stored->next_can_user_level,
|
|
),
|
|
paylinePointsQualKp: (float) ($stored->payline_points_qual_kp ?? 0),
|
|
personalPoints: (float) ($stored->sales_volume_points_KP_sum ?? 0),
|
|
);
|
|
}
|
|
|
|
// v3: Payload um Level-/Quali-Daten erweitert (N1/N4, next_level mit
|
|
// Karriere-Fallback) — alte Cache-Einträge dürfen nicht wiederverwendet werden.
|
|
$cacheKey = "live_business_v3_{$user->id}_{$month}_{$year}";
|
|
$cached = Cache::get($cacheKey);
|
|
if (is_array($cached)) {
|
|
return $cached;
|
|
}
|
|
|
|
$snapshot = $this->calculateLive($user, $month, $year);
|
|
if ($snapshot !== null) {
|
|
Cache::put($cacheKey, $snapshot, now()->addHours(self::CACHE_HOURS));
|
|
|
|
return $snapshot;
|
|
}
|
|
|
|
return $this->buildSnapshot(0.0, 0, 0.0, 0.0, 0.0, true, failed: true);
|
|
}
|
|
|
|
/**
|
|
* Provision "Stand heute" (B1) — alle Bestandteile plus Summe.
|
|
*
|
|
* `failed` wird mit durchgereicht: eine fehlgeschlagene Live-Berechnung liefert
|
|
* Ersatzwerte, aus denen sich rechnerisch 0,00 Euro ergibt. Ohne dieses Kennzeichen
|
|
* waere ein Berechnungsfehler in der Oberflaeche nicht von einem echten Nullmonat
|
|
* zu unterscheiden.
|
|
*
|
|
* @return array{total: float, pp: float, shop: float, growth: float, is_live: bool, failed: bool}
|
|
*/
|
|
public function commission(User $user, int $month, int $year): array
|
|
{
|
|
$snapshot = $this->snapshot($user, $month, $year);
|
|
|
|
return [
|
|
'total' => round($snapshot['pp'] + $snapshot['shop'] + $snapshot['growth'], 2),
|
|
'pp' => $snapshot['pp'],
|
|
'shop' => $snapshot['shop'],
|
|
'growth' => $snapshot['growth'],
|
|
'is_live' => $snapshot['is_live'],
|
|
'failed' => (bool) ($snapshot['failed'] ?? false),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{payline_points: float, paylines: int, pp: float, shop: float, growth: float, is_live: bool, failed: bool}|null
|
|
*/
|
|
private function calculateLive(User $user, int $month, int $year): ?array
|
|
{
|
|
try {
|
|
$treeCalcBot = new TreeCalcBotOptimized($month, $year, 'member');
|
|
$treeCalcBot->initBusinesslUserDetail($user);
|
|
$businessUser = $treeCalcBot->business_user;
|
|
|
|
if (! $businessUser) {
|
|
return null;
|
|
}
|
|
|
|
// Kein `?? 0` auf den Magic-Properties: die Items haben kein __isset,
|
|
// isset() wäre dort immer false und das Ergebnis damit immer 0.
|
|
return $this->buildSnapshot(
|
|
(float) $businessUser->payline_points,
|
|
$this->paylinesFromQualLevel($businessUser->qual_user_level),
|
|
(float) $businessUser->commission_pp_total,
|
|
(float) $businessUser->commission_shop_sales,
|
|
(float) $businessUser->commission_growth_total,
|
|
true,
|
|
qualLevel: $this->normalizeLevel($businessUser->qual_user_level),
|
|
nextLevel: $this->resolveNextLevel(
|
|
$businessUser->qual_user_level_next,
|
|
$businessUser->next_qual_user_level,
|
|
$businessUser->next_can_user_level,
|
|
),
|
|
paylinePointsQualKp: (float) $businessUser->payline_points_qual_kp,
|
|
personalPoints: (float) $businessUser->sales_volume_points_KP_sum,
|
|
);
|
|
} catch (\Throwable $e) {
|
|
\Log::warning("LiveCommissionService: Live-Berechnung fehlgeschlagen für User {$user->id}: ".$e->getMessage());
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Payline-Tiefe (Anzahl bezahlter Ebenen) aus dem Qualifikationslevel.
|
|
*
|
|
* @param array<string, mixed>|object|null $qualUserLevel
|
|
*/
|
|
private function paylinesFromQualLevel(mixed $qualUserLevel): int
|
|
{
|
|
if (is_array($qualUserLevel)) {
|
|
return (int) ($qualUserLevel['paylines'] ?? 0);
|
|
}
|
|
|
|
if (is_object($qualUserLevel)) {
|
|
return (int) ($qualUserLevel->paylines ?? 0);
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Level-Fortschritt für die Level-Kachel (N1, F2): monatliche
|
|
* Qualifikationsstufe plus "zum nächsten Level fehlen X Payline-Punkte /
|
|
* Y persönliches Volumen"; Karriere-Level als Zusatzinfo. Eine Stelle für
|
|
* die Rechnung, damit Kachel und Prognose nicht auseinanderlaufen.
|
|
*
|
|
* Vor der ersten Monatsqualifikation ist `qual_level` null — dann liefert
|
|
* {@see BusinessUserItemOptimized::setFirstQualLevel()} das erste erreichbare
|
|
* Level als `next_level` mit. Der Block darf in diesem Zustand NICHT
|
|
* verschwinden: genau dann ist "dir fehlen noch X Punkte" die nützlichste
|
|
* Information der Seite. `is_qualified` unterscheidet die beiden Fälle.
|
|
*
|
|
* @return array{name: string, is_qualified: bool, career_name: string, next_name: string|null, missing_pp: float, missing_personal: float, is_live: bool}|null
|
|
*/
|
|
public function levelProgress(User $user, int $month, int $year): ?array
|
|
{
|
|
$snapshot = $this->snapshot($user, $month, $year);
|
|
$qualLevel = $snapshot['qual_level'];
|
|
|
|
if (! empty($snapshot['failed'])) {
|
|
return null;
|
|
}
|
|
|
|
$nextLevel = $snapshot['next_level'];
|
|
|
|
if (! is_array($qualLevel) && ! is_array($nextLevel)) {
|
|
return null;
|
|
}
|
|
|
|
// Punktebasis zum nächsten Level: dessen _calculated-Wert rechnet mit den
|
|
// Paylines/dem KP-Überschuss DES nächsten Levels — die eigene
|
|
// payline_points_qual_kp (erreichtes Level) wäre bei mehr Paylines zu klein.
|
|
$paylineBasis = is_array($nextLevel)
|
|
? (float) ($nextLevel['_calculated_payline_points_qual_kp'] ?? $snapshot['payline_points_qual_kp'])
|
|
: 0.0;
|
|
|
|
return [
|
|
'name' => is_array($qualLevel) ? (string) ($qualLevel['name'] ?? '') : '',
|
|
'is_qualified' => is_array($qualLevel),
|
|
'career_name' => $user->user_level?->getLang('name') ?? '',
|
|
'next_name' => is_array($nextLevel) ? (string) ($nextLevel['name'] ?? '') : null,
|
|
'missing_pp' => is_array($nextLevel)
|
|
? max(0.0, (float) ($nextLevel['qual_pp'] ?? 0) - $paylineBasis)
|
|
: 0.0,
|
|
'missing_personal' => is_array($nextLevel)
|
|
? max(0.0, (float) ($nextLevel['qual_kp'] ?? 0) - (float) $snapshot['personal_points'])
|
|
: 0.0,
|
|
'is_live' => (bool) $snapshot['is_live'],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Nächstes Level für die "fehlen noch X Punkte"-Anzeige: qual_user_level_next
|
|
* ist im Normalzustand (Quali-Stufe == Karriere-Level) bewusst null — dann
|
|
* greift der direkt nächste Karriere-Level aus setNextUserLevel()
|
|
* (next_qual_user_level bzw. next_can_user_level, beide mit _calculated-Werten).
|
|
*
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
private function resolveNextLevel(mixed $qualNext, mixed $nextQual, mixed $nextCan): ?array
|
|
{
|
|
return $this->normalizeLevel($qualNext)
|
|
?? $this->normalizeLevel($nextQual)
|
|
?? $this->normalizeLevel($nextCan);
|
|
}
|
|
|
|
/**
|
|
* Level-Daten (JSON aus UserBusiness oder Objekt/Array aus dem Live-Lauf)
|
|
* in ein einheitliches Array bringen; null wenn nichts vorliegt.
|
|
*
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
private function normalizeLevel(mixed $level): ?array
|
|
{
|
|
if (is_object($level)) {
|
|
$level = method_exists($level, 'toArray') ? $level->toArray() : (array) $level;
|
|
}
|
|
|
|
return is_array($level) && $level !== [] ? $level : null;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed>|null $qualLevel
|
|
* @param array<string, mixed>|null $nextLevel
|
|
* @return array{payline_points: float, paylines: int, pp: float, shop: float, growth: float, is_live: bool, failed: bool, qual_level: array<string, mixed>|null, next_level: array<string, mixed>|null, payline_points_qual_kp: float, personal_points: float}
|
|
*/
|
|
private function buildSnapshot(
|
|
float $paylinePoints,
|
|
int $paylines,
|
|
float $pp,
|
|
float $shop,
|
|
float $growth,
|
|
bool $isLive,
|
|
bool $failed = false,
|
|
?array $qualLevel = null,
|
|
?array $nextLevel = null,
|
|
float $paylinePointsQualKp = 0.0,
|
|
float $personalPoints = 0.0,
|
|
): array {
|
|
return [
|
|
'payline_points' => round($paylinePoints, 2),
|
|
'paylines' => $paylines,
|
|
'pp' => round($pp, 2),
|
|
'shop' => round($shop, 2),
|
|
'growth' => round($growth, 2),
|
|
'is_live' => $isLive,
|
|
'failed' => $failed,
|
|
'qual_level' => $qualLevel,
|
|
'next_level' => $nextLevel,
|
|
'payline_points_qual_kp' => round($paylinePointsQualKp, 2),
|
|
'personal_points' => round($personalPoints, 2),
|
|
];
|
|
}
|
|
}
|