Statistik: uebrige Audit-Korrekturen (Abo-Regeln, Prognose, Sortierung)
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>
This commit is contained in:
parent
8d7a98c685
commit
94e2b13384
31 changed files with 1931 additions and 241 deletions
|
|
@ -10,6 +10,7 @@ use App\Services\BusinessPlan\LiveCommissionService;
|
|||
use App\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
|
|
@ -40,6 +41,7 @@ class PaylineForecastService
|
|||
public function __construct(
|
||||
private BackofficeDashboardService $dashboardService,
|
||||
private LiveCommissionService $commissionService,
|
||||
private TeamAboOverviewService $teamAboOverview,
|
||||
) {}
|
||||
|
||||
/**
|
||||
|
|
@ -48,19 +50,27 @@ class PaylineForecastService
|
|||
* bleiben live. Ein fehlgeschlagener Live-Lauf wird nicht gecacht, damit
|
||||
* ein transienter Fehler die Karte nicht 6 h lang leert.
|
||||
*
|
||||
* @return array{paylines: int, member_ids: int[]}
|
||||
* Ebenso wenig gecacht wird eine Tiefe von 0: dieser Zustand tritt am
|
||||
* Monatsanfang bei jedem Berater ohne Karriere-Level auf und löst sich mit
|
||||
* den ersten Punkten des Monats von selbst auf — ein 6-h-Cache würde die
|
||||
* leeren Kacheln bis in den Nachmittag festhalten.
|
||||
*
|
||||
* @return array{paylines: int, member_ids: int[], is_career_fallback: bool}
|
||||
*/
|
||||
public function paylineContext(User $user, int $month, int $year): array
|
||||
{
|
||||
$cacheKey = "payline_context_{$user->id}_{$month}_{$year}";
|
||||
$cached = Cache::get($cacheKey);
|
||||
if (is_array($cached)) {
|
||||
return $cached;
|
||||
// Union statt direkter Rueckgabe: Eintraege aus der Zeit vor dem
|
||||
// Karriere-Fallback kennen is_career_fallback noch nicht und wuerden
|
||||
// beim Leser sonst in eine undefinierte Schluesselwarnung laufen.
|
||||
return $cached + ['is_career_fallback' => false];
|
||||
}
|
||||
|
||||
[$context, $failed] = $this->buildPaylineContext($user, $month, $year);
|
||||
|
||||
if (! $failed) {
|
||||
if (! $failed && $context['paylines'] > 0) {
|
||||
Cache::put($cacheKey, $context, now()->addHours(self::CACHE_HOURS));
|
||||
}
|
||||
|
||||
|
|
@ -90,27 +100,20 @@ class PaylineForecastService
|
|||
|
||||
/**
|
||||
* Neue Abos der Payline-Mitglieder im Monat (Berater- + Kundenabos,
|
||||
* start_date im Monat) — für die "Neue Abos in der Payline"-Kachel (N1).
|
||||
* start_date im Monat) — das "+N"-Badge der "Abos in der Payline"-Kachel (N1).
|
||||
*
|
||||
* Zählt über {@see TeamAboOverviewService::newAboCount()} und damit über
|
||||
* exakt dieselbe Stichtags-Logik wie der Bestand der Kachel: für vergangene
|
||||
* Monate zählt, was am Monatsende bestand. Mit `activeAbo()` (heutiger Stand)
|
||||
* wäre das Badge kleiner als der Bestand daneben und würde mit jedem
|
||||
* beendeten Abo rückwirkend weiter schrumpfen.
|
||||
*/
|
||||
public function newPaylineAboCount(User $user, int $month, int $year): int
|
||||
{
|
||||
$memberIds = $this->paylineContext($user, $month, $year)['member_ids'];
|
||||
|
||||
if ($memberIds === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$startDate = Carbon::create($year, $month, 1)->startOfMonth();
|
||||
$endDate = $startDate->copy()->endOfMonth();
|
||||
|
||||
return UserAbo::query()
|
||||
->activeAbo()
|
||||
->whereBetween('start_date', [$startDate, $endDate])
|
||||
->where(function (Builder $query) use ($memberIds) {
|
||||
$query->where(fn (Builder $inner) => $inner->where('is_for', 'me')->whereIn('user_id', $memberIds))
|
||||
->orWhere(fn (Builder $inner) => $inner->where('is_for', 'ot')->whereIn('member_id', $memberIds));
|
||||
})
|
||||
->count();
|
||||
return $this->teamAboOverview->newAboCount($memberIds, 'user_id', 'me', $month, $year)
|
||||
+ $this->teamAboOverview->newAboCount($memberIds, 'member_id', 'ot', $month, $year);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -135,8 +138,9 @@ class PaylineForecastService
|
|||
* B10: Prognose je Ausführungstermin des LAUFENDEN Monats. Basis ist der
|
||||
* heutige Stand (Kundenumsatz + Payline-Punkte, vom Aufrufer übergeben);
|
||||
* dazu kommen kumuliert die Abo-Punkte aller noch offenen Ausführungen bis
|
||||
* zum jeweiligen Termin (next_date > heute, im laufenden Monat). Bereits
|
||||
* ausgeführte Abos stecken im Ist-Umsatz und werden nicht doppelt gezählt.
|
||||
* zum jeweiligen Termin (im laufenden Monat, Abgrenzung siehe
|
||||
* {@see UserAbo::scopeOpenExecutionUntil()}). Bereits ausgeführte Abos
|
||||
* stecken im Ist-Umsatz und werden nicht doppelt gezählt.
|
||||
*
|
||||
* @return array{base: float, days: array<int, array{day: int, date: string, upcoming: float, total: float, is_past: bool}>}
|
||||
*/
|
||||
|
|
@ -149,8 +153,56 @@ class PaylineForecastService
|
|||
->with('user_abo_items.product')
|
||||
->get();
|
||||
|
||||
return [
|
||||
'base' => $basePoints,
|
||||
'days' => $this->cumulateByDeliveryDay($upcomingAbos, $basePoints, $today),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Dieselbe Termin-Vorschau, aber ausschließlich über die EIGENEN Kundenabos
|
||||
* des Beraters (Kundenwunsch 2026-09-07: „wieviel Volumenpunkte habe ich am
|
||||
* 5. / 10. / 20. / 25. — nur mit meinen Kundenabos").
|
||||
*
|
||||
* Bewusst ohne das eigene Abo und ohne Payline: Basis ist 0, die Kacheln
|
||||
* zeigen also die Punkte, die aus den eigenen Kundenabos bis zum jeweiligen
|
||||
* Termin noch hereinkommen. Das ist ein anderer Zuschnitt als
|
||||
* {@see forecastDays()}, wo eigene Abos in einer Team-Zahl mitlaufen.
|
||||
*
|
||||
* @return array{base: float, days: array<int, array{day: int, date: string, upcoming: float, total: float, is_past: bool}>}
|
||||
*/
|
||||
public function personalCustomerAboForecast(User $user): array
|
||||
{
|
||||
$today = Carbon::today();
|
||||
|
||||
$abos = UserAbo::query()
|
||||
->where('active', true)
|
||||
->where('status', 2)
|
||||
->where('is_for', 'ot')
|
||||
->where('member_id', $user->id)
|
||||
->openExecutionUntil($today, $today->copy()->endOfMonth())
|
||||
->with('user_abo_items.product')
|
||||
->get();
|
||||
|
||||
return [
|
||||
'base' => 0.0,
|
||||
'days' => $this->cumulateByDeliveryDay($abos, 0.0, $today),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Offene Ausführungen auf die vier Liefertermine kumulieren: je Termin die
|
||||
* Punkte aller Abos, deren nächste Ausführung bis einschließlich zu diesem
|
||||
* Tag fällt. Eine Stelle für beide Prognosen, damit Payline- und
|
||||
* Kundenabo-Sicht nicht mit unterschiedlichen Terminregeln rechnen.
|
||||
*
|
||||
* @param Collection<int, UserAbo> $abos
|
||||
* @return array<int, array{day: int, date: string, upcoming: float, total: float, is_past: bool}>
|
||||
*/
|
||||
private function cumulateByDeliveryDay($abos, float $basePoints, Carbon $today): array
|
||||
{
|
||||
$pointsByDay = [];
|
||||
foreach ($upcomingAbos as $abo) {
|
||||
foreach ($abos as $abo) {
|
||||
$day = (int) Carbon::parse($abo->getRawOriginal('next_date'))->day;
|
||||
$pointsByDay[$day] = ($pointsByDay[$day] ?? 0.0) + (float) $abo->getTotalPoints();
|
||||
}
|
||||
|
|
@ -173,10 +225,7 @@ class PaylineForecastService
|
|||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'base' => $basePoints,
|
||||
'days' => $days,
|
||||
];
|
||||
return $days;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -219,14 +268,24 @@ class PaylineForecastService
|
|||
private function buildLevelAndCommissionForecast(User $user, int $month, int $year): ?array
|
||||
{
|
||||
$snapshot = $this->commissionService->snapshot($user, $month, $year);
|
||||
$qualLevel = $snapshot['qual_level'];
|
||||
|
||||
if (! is_array($qualLevel) || ! empty($snapshot['failed'])) {
|
||||
if (! empty($snapshot['failed'])) {
|
||||
return null; // echter Berechnungsfehler — dafuer gibt es keine Prognose
|
||||
}
|
||||
|
||||
$qualLevel = is_array($snapshot['qual_level']) ? $snapshot['qual_level'] : null;
|
||||
$nextLevel = is_array($snapshot['next_level']) ? $snapshot['next_level'] : null;
|
||||
|
||||
// "Noch nicht qualifiziert" ist ein gueltiger Ausgangszustand: wer im Monat
|
||||
// noch keine Stufe erreicht hat, kann die erste durch kommende eigene
|
||||
// Abo-Punkte trotzdem erreichen. Nur wenn es weder eine erreichte noch eine
|
||||
// naechste Stufe gibt, fehlt der Prognose jeder Bezugspunkt.
|
||||
if ($qualLevel === null && $nextLevel === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$today = Carbon::today();
|
||||
$lineByUserId = $this->lineByUserId($user);
|
||||
$lineByUserId = $this->lineByUserId($user, $month, $year);
|
||||
$upcoming = $this->upcomingPointsByLine($user, $lineByUserId, $today, $month, $year);
|
||||
|
||||
$linePointsForecast = $this->currentLinePoints($lineByUserId, $month, $year);
|
||||
|
|
@ -235,7 +294,6 @@ class PaylineForecastService
|
|||
}
|
||||
|
||||
$personalForecast = (float) $snapshot['personal_points'] + $upcoming['personal'];
|
||||
$nextLevel = is_array($snapshot['next_level']) ? $snapshot['next_level'] : null;
|
||||
|
||||
$reached = false;
|
||||
$missingPp = 0.0;
|
||||
|
|
@ -249,6 +307,9 @@ class PaylineForecastService
|
|||
$reached = $missingPp <= 0.0 && $missingPersonal <= 0.0;
|
||||
}
|
||||
|
||||
// Ohne erreichte Stufe und ohne prognostizierten Aufstieg gibt es keine
|
||||
// Provisionssaetze — die PP-Provision bleibt dann 0, Shop und Wachstum zaehlen
|
||||
// trotzdem.
|
||||
$estimationLevel = ($reached && $nextLevel !== null) ? $nextLevel : $qualLevel;
|
||||
$commissionPp = 0.0;
|
||||
for ($line = 1; $line <= (int) ($estimationLevel['paylines'] ?? 0); $line++) {
|
||||
|
|
@ -270,23 +331,63 @@ class PaylineForecastService
|
|||
}
|
||||
|
||||
/**
|
||||
* Ebenen-Zuordnung der gesamten Downline (Linie je User-ID).
|
||||
* Ebenen-Zuordnung der Downline (Linie je User-ID) — beschränkt auf die
|
||||
* Mitglieder, die auch im Provisionsbaum stehen.
|
||||
*
|
||||
* Das ist entscheidend für die Vergleichbarkeit: Die Level-Kachel zeigt die
|
||||
* payline_points aus dem TreeCalcBot, dieser Forecast rechnet die Ebenen aus
|
||||
* {@see BackofficeDashboardService::lineBuckets()} nach. lineBuckets() nimmt
|
||||
* aber JEDEN nicht gelöschten Downline-User, der TreeCalcBot filtert
|
||||
* ({@see BusinessUserItemOptimized::readParentsBusinessUsers()}) und kappt
|
||||
* den Unterbaum unter jedem ausgefilterten Knoten. Ohne denselben Filter
|
||||
* stünden auf einer Seite zwei verschiedene "fehlen noch X Punkte".
|
||||
*
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function lineByUserId(User $user): array
|
||||
private function lineByUserId(User $user, int $month, int $year): array
|
||||
{
|
||||
$map = [];
|
||||
$endOfMonth = Carbon::create($year, $month, 1)->endOfMonth();
|
||||
|
||||
$map = [];
|
||||
$pruned = [];
|
||||
|
||||
// lineBuckets() liefert die Ebenen aufsteigend (BFS) — der Sponsor ist
|
||||
// damit immer schon entschieden, wenn sein Teampartner drankommt.
|
||||
foreach ($this->dashboardService->lineBuckets($user->id) as $line => $users) {
|
||||
foreach ($users as $member) {
|
||||
$map[(int) $member->id] = (int) $line;
|
||||
$memberId = (int) $member->id;
|
||||
|
||||
if (isset($pruned[(int) $member->m_sponsor]) || ! $this->countsInCommissionTree($member, $endOfMonth)) {
|
||||
$pruned[$memberId] = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$map[$memberId] = (int) $line;
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dieselben Bedingungen, mit denen der TreeCalcBot seine Struktur lädt:
|
||||
* kein System-/Adminkonto, Mitgliedsebene und Abrechnungskonto gesetzt, bis
|
||||
* zum Monatsende aktiv geworden.
|
||||
*/
|
||||
private function countsInCommissionTree(User $member, Carbon $endOfMonth): bool
|
||||
{
|
||||
if ((int) $member->id === 1 || $member->admin === null || (int) $member->admin >= 4) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($member->m_level === null || $member->payment_account === null || $member->active_date === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Carbon::parse($member->active_date)->lte($endOfMonth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Offene Abo-Ausführungen des Monats, aufgeteilt in persönliches Volumen
|
||||
* (eigene Abos + eigene Kundenabos) und Team-Punkte je Ebene.
|
||||
|
|
@ -376,15 +477,36 @@ class PaylineForecastService
|
|||
}
|
||||
|
||||
/**
|
||||
* @return array{0: array{paylines: int, member_ids: int[]}, 1: bool} [Kontext, Lauf fehlgeschlagen]
|
||||
* Payline-Tiefe bestimmen und die Mitglieder der bezahlten Ebenen einsammeln.
|
||||
*
|
||||
* Die Tiefe kommt aus der MONATLICHEN Qualifikationsstufe. Die wird jeden
|
||||
* Monat neu erarbeitet und ist am 1. für jeden Berater noch leer
|
||||
* ({@see BusinessUserItemOptimized::calcuQualLevel()} findet erst ab der
|
||||
* qual_kp des niedrigsten Levels ein Ergebnis) — die Struktur-Kacheln
|
||||
* "Teampartner in der Payline" und "Abos in der Payline" stünden dann auf 0,
|
||||
* obwohl sich an der Teamstruktur nichts geändert hat.
|
||||
*
|
||||
* Solange die Monatsquali aussteht, gilt deshalb die Ebenenzahl des
|
||||
* Karriere-Levels (Kundenentscheid 2026-09-01). Punkte- und Provisionswerte
|
||||
* bleiben davon unberührt: die kommen direkt aus dem Snapshot und sind ohne
|
||||
* Qualifikation korrekt 0.
|
||||
*
|
||||
* @return array{0: array{paylines: int, member_ids: int[], is_career_fallback: bool}, 1: bool} [Kontext, Lauf fehlgeschlagen]
|
||||
*/
|
||||
private function buildPaylineContext(User $user, int $month, int $year): array
|
||||
{
|
||||
$snapshot = $this->commissionService->snapshot($user, $month, $year);
|
||||
$failed = (bool) ($snapshot['failed'] ?? false);
|
||||
$paylines = $snapshot['paylines'];
|
||||
$isCareerFallback = false;
|
||||
|
||||
if ($paylines <= 0) {
|
||||
return [['paylines' => 0, 'member_ids' => []], (bool) ($snapshot['failed'] ?? false)];
|
||||
$paylines = (int) ($user->user_level?->paylines ?? 0);
|
||||
$isCareerFallback = $paylines > 0;
|
||||
}
|
||||
|
||||
if ($paylines <= 0) {
|
||||
return [['paylines' => 0, 'member_ids' => [], 'is_career_fallback' => false], $failed];
|
||||
}
|
||||
|
||||
$memberIds = collect($this->dashboardService->lineBuckets($user->id))
|
||||
|
|
@ -394,7 +516,7 @@ class PaylineForecastService
|
|||
->values()
|
||||
->all();
|
||||
|
||||
return [['paylines' => $paylines, 'member_ids' => $memberIds], false];
|
||||
return [['paylines' => $paylines, 'member_ids' => $memberIds, 'is_career_fallback' => $isCareerFallback], $failed];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -460,8 +582,7 @@ class PaylineForecastService
|
|||
return UserAbo::query()
|
||||
->where('active', true)
|
||||
->where('status', 2)
|
||||
->where('next_date', '>', $today->copy()->endOfDay())
|
||||
->where('next_date', '<=', $today->copy()->endOfMonth())
|
||||
->openExecutionUntil($today, $today->copy()->endOfMonth())
|
||||
->where(function (Builder $query) use ($aboUserIds) {
|
||||
$query->where(fn (Builder $inner) => $inner->where('is_for', 'me')->whereIn('user_id', $aboUserIds))
|
||||
->orWhere(fn (Builder $inner) => $inner->where('is_for', 'ot')->whereIn('member_id', $aboUserIds));
|
||||
|
|
|
|||
|
|
@ -137,13 +137,15 @@ class TeamAboOverviewService
|
|||
}
|
||||
|
||||
/**
|
||||
* Die Bestandsabfrage hinter {@see aboStockCount()} — offen, damit die
|
||||
* "+N neu"-Zählung ({@see newAboCount()}) exakt dieselbe Stichtags-Logik
|
||||
* verwendet und nicht in derselben Kachel eine andere Zählweise zeigt.
|
||||
* Die Bestandsabfrage hinter {@see aboStockCount()} — öffentlich, damit jede
|
||||
* "+N neu"-Zählung exakt dieselbe Stichtags-Logik verwendet und nicht in
|
||||
* derselben Kachel eine andere Zählweise steht. Wer nur eine Gesamtzahl
|
||||
* braucht, nimmt {@see newAboCount()}; gruppierte Auswertungen
|
||||
* ({@see TopResultsService}) setzen direkt auf dieser Abfrage auf.
|
||||
*
|
||||
* @param int[] $userIds
|
||||
*/
|
||||
private function aboStockQuery(array $userIds, string $userColumn, string $isFor, int $month, int $year): Builder
|
||||
public function aboStockQuery(array $userIds, string $userColumn, string $isFor, int $month, int $year): Builder
|
||||
{
|
||||
$endOfMonth = Carbon::create($year, $month, 1)->endOfMonth();
|
||||
|
||||
|
|
@ -173,7 +175,18 @@ class TeamAboOverviewService
|
|||
->whereColumn('r.user_abo_id', 'h.user_abo_id')
|
||||
->where('r.action', UserAboStatusHistory::ACTION_REACTIVATED)
|
||||
->where('r.created_at', '<=', $endOfMonth)
|
||||
->whereColumn('r.created_at', '>', 'h.created_at');
|
||||
// Gleicher Zeitstempel kommt vor (created_at ist
|
||||
// sekundengenau): dann entscheidet die Reihenfolge der
|
||||
// Einträge, sonst gilt eine Reaktivierung in derselben
|
||||
// Sekunde nicht und das Abo fällt aus dem Bestand.
|
||||
->where(function ($later) {
|
||||
$later
|
||||
->whereColumn('r.created_at', '>', 'h.created_at')
|
||||
->orWhere(fn ($sameSecond) => $sameSecond
|
||||
->whereColumn('r.created_at', '=', 'h.created_at')
|
||||
->whereColumn('r.id', '>', 'h.id')
|
||||
);
|
||||
});
|
||||
});
|
||||
})
|
||||
// Heute beendete Abos ohne datierbares Ende (z. B. Admin-Toggle vor
|
||||
|
|
@ -407,9 +420,10 @@ class TeamAboOverviewService
|
|||
|
||||
/**
|
||||
* Abo-Punkte der noch offenen Ausführungen bis einschließlich zum letzten
|
||||
* Liefertag (25.) des Monats: aktive Abos der Berater mit next_date nach
|
||||
* heute und im angefragten Monat. Für vergangene/zukünftige Monate 0 —
|
||||
* die Prognose ergibt nur für den laufenden Monat Sinn.
|
||||
* Liefertag (25.) des Monats: aktive Abos der Berater, deren Ausführung noch
|
||||
* aussteht ({@see UserAbo::scopeOpenExecutionUntil()}). Für
|
||||
* vergangene/zukünftige Monate 0 — die Prognose ergibt nur für den laufenden
|
||||
* Monat Sinn.
|
||||
*
|
||||
* @param int[] $userIds
|
||||
*/
|
||||
|
|
@ -424,7 +438,10 @@ class TeamAboOverviewService
|
|||
$lastDeliveryDay = (int) max(UserAbo::$aboDeliveryDays);
|
||||
$windowEnd = Carbon::create($year, $month, 1)->addDays($lastDeliveryDay - 1)->endOfDay();
|
||||
|
||||
if ($windowEnd->lte($today->copy()->endOfDay())) {
|
||||
// A13: Am 25. selbst ist das Fenster noch offen, solange der
|
||||
// Ausführungsjob nicht gelaufen ist — erst ab dem 26. gibt es nichts mehr
|
||||
// zu prognostizieren.
|
||||
if ($windowEnd->lt($today->copy()->startOfDay())) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
|
|
@ -435,8 +452,7 @@ class TeamAboOverviewService
|
|||
->where('user_abos.active', true)
|
||||
->where('user_abos.status', 2)
|
||||
->where('user_abo_items.comp', 0)
|
||||
->where('user_abos.next_date', '>', $today->copy()->endOfDay())
|
||||
->where('user_abos.next_date', '<=', $windowEnd)
|
||||
->openExecutionUntil($today, $windowEnd)
|
||||
->where(function (Builder $query) use ($userIds) {
|
||||
$query->where(fn (Builder $inner) => $inner->where('user_abos.is_for', 'me')->whereIn('user_abos.user_id', $userIds))
|
||||
->orWhere(fn (Builder $inner) => $inner->where('user_abos.is_for', 'ot')->whereIn('user_abos.member_id', $userIds));
|
||||
|
|
@ -487,8 +503,12 @@ class TeamAboOverviewService
|
|||
*
|
||||
* @param int[] $userIds
|
||||
*/
|
||||
private function newAboCount(array $userIds, string $userColumn, string $isFor, int $month, int $year): int
|
||||
public function newAboCount(array $userIds, string $userColumn, string $isFor, int $month, int $year): int
|
||||
{
|
||||
if ($userIds === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$startDate = Carbon::create($year, $month, 1)->startOfMonth();
|
||||
|
||||
return $this->aboStockQuery($userIds, $userColumn, $isFor, $month, $year)
|
||||
|
|
|
|||
|
|
@ -526,6 +526,10 @@ class AboHelper
|
|||
: (int) $userAbo->payone_userid;
|
||||
|
||||
\DB::transaction(function () use ($userAbo, $order, $aboInterval, $shopping_payment, $payoneUserId, $isStripe): void {
|
||||
// War das Abo deaktiviert, ist das hier eine Reaktivierung. Der
|
||||
// Protokolleintrag entsteht in derselben Transaktion am Model
|
||||
// ({@see UserAbo::logStatusTransition()}) — ohne ihn verschwaende das Abo
|
||||
// aus der historischen Bestandsrueckrechnung.
|
||||
$userAbo->update([
|
||||
'status' => 2,
|
||||
'active' => true,
|
||||
|
|
|
|||
|
|
@ -73,7 +73,12 @@ class LiveCommissionService
|
|||
/**
|
||||
* Provision "Stand heute" (B1) — alle Bestandteile plus Summe.
|
||||
*
|
||||
* @return array{total: float, pp: float, shop: float, growth: float, is_live: bool}
|
||||
* `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
|
||||
{
|
||||
|
|
@ -85,6 +90,7 @@ class LiveCommissionService
|
|||
'shop' => $snapshot['shop'],
|
||||
'growth' => $snapshot['growth'],
|
||||
'is_live' => $snapshot['is_live'],
|
||||
'failed' => (bool) ($snapshot['failed'] ?? false),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -151,19 +157,29 @@ class LiveCommissionService
|
|||
* Y persönliches Volumen"; Karriere-Level als Zusatzinfo. Eine Stelle für
|
||||
* die Rechnung, damit Kachel und Prognose nicht auseinanderlaufen.
|
||||
*
|
||||
* @return array{name: string, career_name: string, next_name: string|null, missing_pp: float, missing_personal: float, is_live: bool}|null
|
||||
* 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 (! is_array($qualLevel) || ! empty($snapshot['failed'])) {
|
||||
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.
|
||||
|
|
@ -172,7 +188,8 @@ class LiveCommissionService
|
|||
: 0.0;
|
||||
|
||||
return [
|
||||
'name' => (string) ($qualLevel['name'] ?? ''),
|
||||
'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)
|
||||
|
|
|
|||
|
|
@ -85,13 +85,19 @@ class AnnualFeeDueService
|
|||
return $groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Innerhalb eines Monats" heisst: gleicher Tag im Folgemonat, an Monatsenden
|
||||
* begrenzt auf dessen letzten Tag. `addMonth()` laeuft dort ueber — vom 31.01.
|
||||
* aus landet es am 03.03. und haette den 02.03. noch als "innerhalb eines
|
||||
* Monats" gezaehlt.
|
||||
*/
|
||||
private function groupKeyFor(Carbon $dueDate, Carbon $now): ?string
|
||||
{
|
||||
return match (true) {
|
||||
$dueDate->lte($now) => self::GROUP_OVERDUE,
|
||||
$dueDate->lte($now->copy()->addMonth()) => self::GROUP_DUE_1_MONTH,
|
||||
$dueDate->lte($now->copy()->addMonths(2)) => self::GROUP_DUE_2_MONTHS,
|
||||
$dueDate->lte($now->copy()->addMonths(3)) => self::GROUP_DUE_3_MONTHS,
|
||||
$dueDate->lte($now->copy()->addMonthNoOverflow()) => self::GROUP_DUE_1_MONTH,
|
||||
$dueDate->lte($now->copy()->addMonthsNoOverflow(2)) => self::GROUP_DUE_2_MONTHS,
|
||||
$dueDate->lte($now->copy()->addMonthsNoOverflow(3)) => self::GROUP_DUE_3_MONTHS,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
namespace App\Services\Team;
|
||||
|
||||
use App\Models\UserAbo;
|
||||
use App\Services\Abo\PaylineForecastService;
|
||||
use App\Services\Abo\TeamAboOverviewService;
|
||||
use App\Services\Backoffice\BackofficeDashboardService;
|
||||
use App\Services\Backoffice\SalesVolumePoints;
|
||||
use App\User;
|
||||
|
|
@ -33,7 +33,10 @@ class TopResultsService
|
|||
|
||||
private const TOP_LIMIT = 3;
|
||||
|
||||
public function __construct(private BackofficeDashboardService $dashboardService) {}
|
||||
public function __construct(
|
||||
private BackofficeDashboardService $dashboardService,
|
||||
private TeamAboOverviewService $teamAboOverview,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
|
|
@ -160,7 +163,13 @@ class TopResultsService
|
|||
/**
|
||||
* Neue aktive Abos im Monat je Berater: neue Kundenabos (member_id, is_for
|
||||
* 'ot') plus neues Eigenabo (user_id, is_for 'me') — Kundenentscheid N6/F7b.
|
||||
* Zählweise wie {@see BackofficeDashboardService::newAboCount()}.
|
||||
*
|
||||
* Stichtags-Logik wie bei den Abo-Kacheln des Dashboards
|
||||
* ({@see TeamAboOverviewService::aboStockQuery()}): für vergangene Monate
|
||||
* zählt, was am Monatsende bestand. Mit `activeAbo()` (heutiger Stand)
|
||||
* schrumpfte die Rangliste eines abgeschlossenen Monats mit jedem beendeten
|
||||
* Abo nachträglich — die Top-3-Neupartner daneben sind über
|
||||
* {@see User::scopeNewPartnerIn()} bereits stichtagsstabil.
|
||||
*
|
||||
* @param int[] $teamUserIds
|
||||
* @return array<int, int> user_id => Anzahl
|
||||
|
|
@ -174,11 +183,9 @@ class TopResultsService
|
|||
$startDate = Carbon::create($year, $month, 1)->startOfMonth();
|
||||
$endDate = Carbon::create($year, $month, 1)->endOfMonth();
|
||||
|
||||
$newAboQuery = fn (string $userColumn, string $isFor) => UserAbo::query()
|
||||
->activeAbo()
|
||||
$newAboQuery = fn (string $userColumn, string $isFor) => $this->teamAboOverview
|
||||
->aboStockQuery($teamUserIds, $userColumn, $isFor, $month, $year)
|
||||
->whereBetween('start_date', [$startDate, $endDate])
|
||||
->whereIn($userColumn, $teamUserIds)
|
||||
->where('is_for', $isFor)
|
||||
->groupBy($userColumn)
|
||||
->selectRaw($userColumn.' as uid, COUNT(*) as value')
|
||||
->pluck('value', 'uid');
|
||||
|
|
@ -196,8 +203,14 @@ class TopResultsService
|
|||
|
||||
/**
|
||||
* Baut aus user_id=>Wert die Top-3-Zeilen (Foto/Name), sortiert nach Wert
|
||||
* absteigend, bei Gleichstand nach Name. Es werden nur die Kandidaten
|
||||
* hydriert, die die Top 3 noch erreichen können.
|
||||
* absteigend, bei Gleichstand nach Name.
|
||||
*
|
||||
* A11: Der Namens-Tiebreak greift erst nach dem Wert — die Kandidatenmenge
|
||||
* darf deshalb nicht mitten durch einen Gleichstand geschnitten werden, sonst
|
||||
* entscheidet die Eingabereihenfolge der Abfrage statt des Namens. Deshalb
|
||||
* zwei Schritte vor dem Hydrieren: Kandidaten ohne Account fallen vorher weg
|
||||
* (sie könnten die Auswahl sonst leer laufen lassen), und die Auswahlgrenze
|
||||
* wird um alle Gleichstände an dieser Grenze erweitert.
|
||||
*
|
||||
* @param array<int, float|int> $valuesByUserId
|
||||
* @return array<int, array{user_id: int, first_name: string, last_name: string, is_self: bool, value: float|int}>
|
||||
|
|
@ -209,12 +222,22 @@ class TopResultsService
|
|||
return [];
|
||||
}
|
||||
|
||||
arsort($valuesByUserId);
|
||||
// Nur Ids: die Namen kommen erst für die tatsächlich nötigen Kandidaten.
|
||||
$withAccount = User::query()
|
||||
->whereIn('id', array_keys($valuesByUserId))
|
||||
->whereHas('account')
|
||||
->pluck('id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->all();
|
||||
|
||||
$valuesByUserId = array_intersect_key($valuesByUserId, array_flip($withAccount));
|
||||
if ($valuesByUserId === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
arsort($valuesByUserId);
|
||||
$valuesByUserId = self::takeIncludingTies($valuesByUserId, self::TOP_LIMIT);
|
||||
|
||||
// Kandidaten in Wert-Reihenfolge hydrieren — Zeilen ohne Account werden
|
||||
// übersprungen und von Nachrückern aufgefüllt; der Puffer begrenzt die
|
||||
// Hydrierung bei großen Teams.
|
||||
$valuesByUserId = array_slice($valuesByUserId, 0, self::TOP_LIMIT + 22, true);
|
||||
$users = User::query()
|
||||
->with('account')
|
||||
->whereIn('id', array_keys($valuesByUserId))
|
||||
|
|
@ -237,9 +260,43 @@ class TopResultsService
|
|||
];
|
||||
}
|
||||
|
||||
usort($rows, fn (array $a, array $b) => [$b['value'], $a['first_name'], $a['last_name']]
|
||||
<=> [$a['value'], $b['first_name'], $b['last_name']]);
|
||||
// user_id als letzte Regel: bei gleichem Wert UND gleichem Namen ist die
|
||||
// Reihenfolge sonst nicht reproduzierbar.
|
||||
usort($rows, fn (array $a, array $b) => [$b['value'], $a['first_name'], $a['last_name'], $a['user_id']]
|
||||
<=> [$a['value'], $b['first_name'], $b['last_name'], $b['user_id']]);
|
||||
|
||||
return array_slice($rows, 0, self::TOP_LIMIT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Die ersten $limit Einträge einer absteigend sortierten Werteliste plus alle
|
||||
* weiteren, die mit dem letzten davon gleichauf liegen. Damit ist garantiert,
|
||||
* dass jeder Kandidat, der die Top 3 über den Namens-Tiebreak noch erreichen
|
||||
* kann, in der Auswahl bleibt.
|
||||
*
|
||||
* @param array<int, float|int> $sortedValues
|
||||
* @return array<int, float|int>
|
||||
*/
|
||||
private static function takeIncludingTies(array $sortedValues, int $limit): array
|
||||
{
|
||||
$taken = [];
|
||||
$boundaryValue = null;
|
||||
|
||||
foreach ($sortedValues as $userId => $value) {
|
||||
if (count($taken) < $limit) {
|
||||
$taken[$userId] = $value;
|
||||
$boundaryValue = $value;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($value != $boundaryValue) {
|
||||
break;
|
||||
}
|
||||
|
||||
$taken[$userId] = $value;
|
||||
}
|
||||
|
||||
return $taken;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue