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>
302 lines
11 KiB
PHP
302 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Team;
|
|
|
|
use App\Services\Abo\PaylineForecastService;
|
|
use App\Services\Abo\TeamAboOverviewService;
|
|
use App\Services\Backoffice\BackofficeDashboardService;
|
|
use App\Services\Backoffice\SalesVolumePoints;
|
|
use App\User;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Support\Facades\Cache;
|
|
|
|
/**
|
|
* "Die besten Ergebnisse des Monats" (N6): drei teaminterne Top-3-Ranglisten
|
|
* für das Home-Dashboard — persönliches Volumen, Neupartner, neue Abos.
|
|
*
|
|
* Basis ist die Downline des Betrachters ({@see BackofficeDashboardService::activeTeamUserIds()})
|
|
* inklusive des Betrachters selbst. Ergebnis wird 6 h gecacht
|
|
* (Muster payline_context_*, {@see PaylineForecastService}); die Profilfotos
|
|
* kommen bewusst erst beim Lesen dazu ({@see withProfileImages()}).
|
|
*/
|
|
class TopResultsService
|
|
{
|
|
/**
|
|
* Sichtbarkeits-Scope der Ranglisten. 'team' = Downline des Betrachters
|
|
* (Kundenentscheid 2026-08, teamintern). 'company' (firmenweit, EIN Ranking
|
|
* für alle) ist als spätere Option vorgesehen — dafür nur
|
|
* {@see scopedUserIds()} erweitern, die Ranglisten-Logik bleibt gleich.
|
|
*/
|
|
public const SCOPE = 'team';
|
|
|
|
private const CACHE_HOURS = 6;
|
|
|
|
private const TOP_LIMIT = 3;
|
|
|
|
public function __construct(
|
|
private BackofficeDashboardService $dashboardService,
|
|
private TeamAboOverviewService $teamAboOverview,
|
|
) {}
|
|
|
|
/**
|
|
* @return array{
|
|
* top_volume: array<int, array{user_id: int, first_name: string, last_name: string, is_self: bool, profile_image_url: string|null, value: float|int}>,
|
|
* top_recruiters: array<int, array{user_id: int, first_name: string, last_name: string, is_self: bool, profile_image_url: string|null, value: float|int}>,
|
|
* top_new_abos: array<int, array{user_id: int, first_name: string, last_name: string, is_self: bool, profile_image_url: string|null, value: float|int}>
|
|
* }
|
|
*/
|
|
public function monthlyTop3(User $user, int $month, int $year): array
|
|
{
|
|
$cacheKey = "top_results_{$user->id}_{$month}_{$year}";
|
|
$cached = Cache::get($cacheKey);
|
|
if (is_array($cached)) {
|
|
return $this->withProfileImages($cached);
|
|
}
|
|
|
|
$teamUserIds = $this->scopedUserIds($user);
|
|
$viewerId = (int) $user->id;
|
|
|
|
$result = [
|
|
'top_volume' => $this->rankRows($this->volumeByUserId($teamUserIds, $month, $year), $viewerId),
|
|
'top_recruiters' => $this->rankRows($this->newPartnerCountBySponsorId($teamUserIds, $month, $year), $viewerId),
|
|
'top_new_abos' => $this->rankRows($this->newAboCountByUserId($teamUserIds, $month, $year), $viewerId),
|
|
];
|
|
|
|
Cache::put($cacheKey, $result, now()->addHours(self::CACHE_HOURS));
|
|
|
|
return $this->withProfileImages($result);
|
|
}
|
|
|
|
/**
|
|
* Team-Ermittlung gekapselt für den Scope-Schalter {@see self::SCOPE}.
|
|
*
|
|
* Der Betrachter zählt mit (Kundenwunsch aus der Juli-Runde: „ich" gehört in
|
|
* die Ranglisten) — konsistent zu den Kundenabo-Ranglisten der VIP-Statistik.
|
|
*
|
|
* @return int[]
|
|
*/
|
|
private function scopedUserIds(User $viewer): array
|
|
{
|
|
return array_values(array_unique(array_merge(
|
|
[(int) $viewer->id],
|
|
$this->dashboardService->activeTeamUserIds($viewer->id)
|
|
)));
|
|
}
|
|
|
|
/**
|
|
* Profilfoto-URLs erst beim Lesen ergänzen, nicht mitcachen: der Dateiname
|
|
* ändert sich bei jedem Upload, eine gecachte URL zeigte sonst bis zu 6 h
|
|
* lang ein totes Bild.
|
|
*
|
|
* @param array<string, array<int, array<string, mixed>>> $result
|
|
* @return array<string, array<int, array<string, mixed>>>
|
|
*/
|
|
private function withProfileImages(array $result): array
|
|
{
|
|
$userIds = collect($result)->flatten(1)->pluck('user_id')->unique()->values()->all();
|
|
|
|
if ($userIds === []) {
|
|
return $result;
|
|
}
|
|
|
|
// Das Konto haengt ueber users.account_id (belongsTo), nicht ueber eine
|
|
// user_id-Spalte an user_accounts — deshalb ueber die User laden.
|
|
$imageUrls = User::query()
|
|
->with('account')
|
|
->whereIn('id', $userIds)
|
|
->get()
|
|
->mapWithKeys(fn (User $member) => [(int) $member->id => $member->account?->getProfileImageUrl()]);
|
|
|
|
foreach ($result as $key => $rows) {
|
|
foreach ($rows as $index => $row) {
|
|
$result[$key][$index]['profile_image_url'] = $imageUrls->get((int) $row['user_id']);
|
|
}
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Persönliches Monatsvolumen (Gesamtpunkte = eigene + Kundenpunkte) je
|
|
* Berater — eine gebatchte Query über user_sales_volumes.
|
|
*
|
|
* @param int[] $teamUserIds
|
|
* @return array<int, float> user_id => Punkte (> 0)
|
|
*/
|
|
private function volumeByUserId(array $teamUserIds, int $month, int $year): array
|
|
{
|
|
if ($teamUserIds === []) {
|
|
return [];
|
|
}
|
|
|
|
return SalesVolumePoints::totalPointsByUser($teamUserIds, $month, $year)
|
|
->filter(fn (float $points) => $points > 0)
|
|
->map(fn (float $points) => round($points, 2))
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* Direkt gesponserte Neupartner im Monat je Sponsor — Definition identisch
|
|
* zu {@see BackofficeDashboardService::newPartnersCount()} (m_level gesetzt,
|
|
* aktives Abrechnungskonto, active_date im Monat), Zuordnung über
|
|
* users.m_sponsor; nur Sponsoren aus dem Team.
|
|
*
|
|
* @param int[] $teamUserIds
|
|
* @return array<int, int> sponsor_user_id => Anzahl
|
|
*/
|
|
private function newPartnerCountBySponsorId(array $teamUserIds, int $month, int $year): array
|
|
{
|
|
if ($teamUserIds === []) {
|
|
return [];
|
|
}
|
|
|
|
return User::query()
|
|
->firstlinesOf($teamUserIds)
|
|
->newPartnerIn($month, $year)
|
|
->groupBy('m_sponsor')
|
|
->selectRaw('m_sponsor, COUNT(*) as value')
|
|
->pluck('value', 'm_sponsor')
|
|
->map(fn ($value) => (int) $value)
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* 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
|
|
*/
|
|
private function newAboCountByUserId(array $teamUserIds, int $month, int $year): array
|
|
{
|
|
if ($teamUserIds === []) {
|
|
return [];
|
|
}
|
|
|
|
$startDate = Carbon::create($year, $month, 1)->startOfMonth();
|
|
$endDate = Carbon::create($year, $month, 1)->endOfMonth();
|
|
|
|
$newAboQuery = fn (string $userColumn, string $isFor) => $this->teamAboOverview
|
|
->aboStockQuery($teamUserIds, $userColumn, $isFor, $month, $year)
|
|
->whereBetween('start_date', [$startDate, $endDate])
|
|
->groupBy($userColumn)
|
|
->selectRaw($userColumn.' as uid, COUNT(*) as value')
|
|
->pluck('value', 'uid');
|
|
|
|
$counts = [];
|
|
foreach ($newAboQuery('member_id', 'ot') as $userId => $value) {
|
|
$counts[(int) $userId] = ($counts[(int) $userId] ?? 0) + (int) $value;
|
|
}
|
|
foreach ($newAboQuery('user_id', 'me') as $userId => $value) {
|
|
$counts[(int) $userId] = ($counts[(int) $userId] ?? 0) + (int) $value;
|
|
}
|
|
|
|
return $counts;
|
|
}
|
|
|
|
/**
|
|
* Baut aus user_id=>Wert die Top-3-Zeilen (Foto/Name), sortiert nach Wert
|
|
* 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}>
|
|
*/
|
|
private function rankRows(array $valuesByUserId, int $viewerId): array
|
|
{
|
|
$valuesByUserId = array_filter($valuesByUserId, fn ($value) => $value > 0);
|
|
if ($valuesByUserId === []) {
|
|
return [];
|
|
}
|
|
|
|
// 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);
|
|
|
|
$users = User::query()
|
|
->with('account')
|
|
->whereIn('id', array_keys($valuesByUserId))
|
|
->get()
|
|
->keyBy('id');
|
|
|
|
$rows = [];
|
|
foreach ($valuesByUserId as $userId => $value) {
|
|
$user = $users->get($userId);
|
|
if (! $user || ! $user->account) {
|
|
continue;
|
|
}
|
|
|
|
$rows[] = [
|
|
'user_id' => (int) $userId,
|
|
'first_name' => (string) $user->account->first_name,
|
|
'last_name' => (string) $user->account->last_name,
|
|
'is_self' => (int) $userId === $viewerId,
|
|
'value' => $value,
|
|
];
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|