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
92344f7080
commit
5a38f4ac3e
31 changed files with 1931 additions and 241 deletions
|
|
@ -13,6 +13,7 @@ use App\Models\UserAbo;
|
|||
use App\Models\UserAboItem;
|
||||
use App\Models\UserAboOneTimeItem;
|
||||
use App\Repositories\AboRepository;
|
||||
use App\Services\Abo\PaylineForecastService;
|
||||
use App\Services\AboHelper;
|
||||
use App\Services\AboItemHistoryService;
|
||||
use App\Services\AboOneTimeService;
|
||||
|
|
@ -30,6 +31,16 @@ use Yard;
|
|||
|
||||
class AboController extends Controller
|
||||
{
|
||||
/**
|
||||
* Relationen, die die Kundenabo-Liste je Zeile auswertet — inklusive
|
||||
* `user_abo_orders` fuer die Anzahl bezahlter Lieferungen
|
||||
* ({@see UserAbo::getCountPaidOrders()}). Ohne Vorladen entsteht dort eine
|
||||
* Abfrage pro dargestelltem Abo.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
private const CUSTOMER_LIST_RELATIONS = ['user_abo_items', 'user_abo_items.product', 'shopping_user', 'user_abo_orders'];
|
||||
|
||||
protected $aboRepository;
|
||||
|
||||
public function __construct(AboRepository $aboRepository)
|
||||
|
|
@ -75,7 +86,7 @@ class AboController extends Controller
|
|||
->where('is_for', 'ot');
|
||||
|
||||
$user_abos = (clone $baseQuery)
|
||||
->with(['user_abo_items', 'user_abo_items.product', 'shopping_user'])
|
||||
->with(self::CUSTOMER_LIST_RELATIONS)
|
||||
->addedInMonth($selectedYear, $selectedMonth)
|
||||
->orderBy('id', 'desc')
|
||||
->get();
|
||||
|
|
@ -85,6 +96,11 @@ class AboController extends Controller
|
|||
'abo_payment_links' => $this->getAboPaymentLinks(),
|
||||
'view' => 'ot',
|
||||
'isAdmin' => false,
|
||||
// Liefertag-Vorschau nur aus den eigenen Kundenabos
|
||||
// (Kundenwunsch 2026-09-07), und nur im laufenden Monat sinnvoll.
|
||||
'customerAboForecast' => $selectedMonth === 0 || ($selectedMonth === (int) now()->month && $selectedYear === (int) now()->year)
|
||||
? app(PaylineForecastService::class)->personalCustomerAboForecast(\Auth::user())
|
||||
: null,
|
||||
'chartData' => AboHelper::getMonthlyAboCounts($baseQuery, $selectedYear, 'ot', \Auth::user()->id),
|
||||
'chartYear' => $selectedYear,
|
||||
'chartYears' => HTMLHelper::getYearRange(2026),
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ class BackofficeStatisticsController extends Controller
|
|||
$statistics = $this->dashboardService->overview($request->user(), $selectedMonth, $selectedYear);
|
||||
$performance = [
|
||||
'duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
|
||||
'source' => $statistics['_meta']['source'] ?? 'live',
|
||||
'source_label' => $statistics['_meta']['source_label'] ?? 'Live',
|
||||
'calculated_at' => $statistics['_meta']['calculated_at'] ?? null,
|
||||
];
|
||||
|
|
@ -114,11 +115,11 @@ class BackofficeStatisticsController extends Controller
|
|||
fputcsv($output, $this->csvHeaders($details['metric']), ';');
|
||||
|
||||
foreach ($details['rows'] as $row) {
|
||||
fputcsv($output, $this->csvRow($details['metric'], $row), ';');
|
||||
fputcsv($output, self::csvSafeRow($this->csvRow($details['metric'], $row)), ';');
|
||||
}
|
||||
|
||||
fputcsv($output, []);
|
||||
fputcsv($output, $this->csvSummaryRow($details), ';');
|
||||
fputcsv($output, self::csvSafeRow($this->csvSummaryRow($details)), ';');
|
||||
|
||||
fclose($output);
|
||||
}, $filename, [
|
||||
|
|
@ -143,11 +144,11 @@ class BackofficeStatisticsController extends Controller
|
|||
fputcsv($output, $this->overviewCsvHeaders(), ';');
|
||||
|
||||
foreach ($statistics['lines'] as $line) {
|
||||
fputcsv($output, $this->overviewCsvRow($line), ';');
|
||||
fputcsv($output, self::csvSafeRow($this->overviewCsvRow($line)), ';');
|
||||
}
|
||||
|
||||
fputcsv($output, []);
|
||||
fputcsv($output, $this->overviewCsvRow($statistics['totals']), ';');
|
||||
fputcsv($output, self::csvSafeRow($this->overviewCsvRow($statistics['totals'])), ';');
|
||||
|
||||
fclose($output);
|
||||
}, $filename, [
|
||||
|
|
@ -220,6 +221,30 @@ class BackofficeStatisticsController extends Controller
|
|||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Textzellen fuer Tabellenprogramme als Text kennzeichnen.
|
||||
*
|
||||
* `fputcsv()` schuetzt die Dateistruktur, nicht die Interpretation: Ein Feld, das
|
||||
* mit `=`, `+`, `-` oder `@` beginnt, wird von Excel und LibreOffice als Formel
|
||||
* gelesen. Die Werte stammen aus Namen, Kontaktdaten und Freitexten anderer
|
||||
* Personen — also nicht aus der Hand dessen, der den Export oeffnet.
|
||||
*
|
||||
* Zahlen bleiben unangetastet, damit die Spalten rechenbar bleiben.
|
||||
*
|
||||
* @param array<int, mixed> $row
|
||||
* @return array<int, mixed>
|
||||
*/
|
||||
private static function csvSafeRow(array $row): array
|
||||
{
|
||||
return array_map(static function ($value) {
|
||||
if (! is_string($value) || $value === '') {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return str_contains("=+-@\t\r", $value[0]) ? "'".$value : $value;
|
||||
}, $row);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
|
|
@ -238,7 +263,7 @@ class BackofficeStatisticsController extends Controller
|
|||
}
|
||||
|
||||
if ($metric === 'new_partners') {
|
||||
return ['Name', 'E-Mail', 'Karriere-Level', 'Sponsor', 'Kundenpunkte', 'Punkte gesamt', 'Aktiv seit', 'Account gueltig bis'];
|
||||
return ['Name', 'E-Mail', 'Telefon', 'Karriere-Level', 'Sponsor', 'Punkte seiner Kunden', 'Gesamt inkl. Eigenbestellung', 'Aktiv seit', 'Account gueltig bis'];
|
||||
}
|
||||
|
||||
return ['Name', 'E-Mail', 'Karriere-Level', 'Aktiv seit', 'Account gueltig bis', 'Account Status'];
|
||||
|
|
@ -293,6 +318,7 @@ class BackofficeStatisticsController extends Controller
|
|||
return [
|
||||
$row['name'] ?? '',
|
||||
$row['email'] ?? '',
|
||||
$row['phone'] ?? '',
|
||||
$row['career_level'] ?? '',
|
||||
$row['sponsor'] ?? '',
|
||||
$row['customer_points'] ?? 0,
|
||||
|
|
@ -334,7 +360,10 @@ class BackofficeStatisticsController extends Controller
|
|||
}
|
||||
|
||||
if ($metric === 'new_partners') {
|
||||
return ['Summe', $summary['count'].' Eintraege', '', '', $summary['customer_points'], $summary['total_points'], '', ''];
|
||||
// Neun Werte fuer neun Spalten: die Telefonspalte kam nachtraeglich dazu,
|
||||
// wodurch die Summen um eine Spalte verrutscht waren (Kundenpunkte
|
||||
// standen unter "Sponsor").
|
||||
return ['Summe', $summary['count'].' Eintraege', '', '', '', $summary['customer_points'], $summary['total_points'], '', ''];
|
||||
}
|
||||
|
||||
return ['Summe', $summary['count'].' Eintraege', '', '', '', ''];
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
namespace App\Models;
|
||||
|
||||
use App\Enums\PaymentProvider;
|
||||
use App\Services\Abo\TeamAboOverviewService;
|
||||
use App\Services\AboPaymentMethodService;
|
||||
use App\Services\Backoffice\BackofficeDashboardService;
|
||||
use App\Services\Util;
|
||||
|
|
@ -106,6 +107,72 @@ class UserAbo extends Model
|
|||
$abo->saveQuietly();
|
||||
}
|
||||
});
|
||||
|
||||
static::updated(function (UserAbo $abo): void {
|
||||
$abo->logStatusTransition();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Kanal des laufenden Schreibvorgangs (Formular-View) für das Statusprotokoll.
|
||||
* Systempfade — Cron, Zahlungs-Recovery, Webhooks — lassen ihn leer und
|
||||
* erscheinen in der Historie als „System".
|
||||
*/
|
||||
protected static ?string $statusLogView = null;
|
||||
|
||||
/**
|
||||
* Führt den Callback aus und schreibt alle dabei entstehenden Protokolleinträge
|
||||
* mit diesem Kanal. Verschachtelung ist erlaubt: der vorherige Kanal wird
|
||||
* wiederhergestellt, auch wenn der Callback eine Exception wirft.
|
||||
*/
|
||||
public static function withStatusLogView(?string $view, \Closure $callback): mixed
|
||||
{
|
||||
$previous = static::$statusLogView;
|
||||
static::$statusLogView = $view;
|
||||
|
||||
try {
|
||||
return $callback();
|
||||
} finally {
|
||||
static::$statusLogView = $previous;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A16: Jeder Wechsel des Aktiv-Zustands wird protokolliert, unabhängig vom
|
||||
* Schreibpfad (Formular, Admin, Zahlungs-Recovery, Retry, externe Erledigung).
|
||||
*
|
||||
* Hintergrund: Die historische Bestandsrückrechnung in
|
||||
* {@see TeamAboOverviewService::aboStockQuery()} verwirft ein
|
||||
* Abo, solange eine protokollierte Deaktivierung nicht durch eine spätere
|
||||
* Reaktivierung aufgehoben ist. Ein Schreibpfad, der still auf `active = true`
|
||||
* zurücksetzt, nimmt das Abo damit dauerhaft aus allen vergangenen Monaten —
|
||||
* deshalb liegt der Eintrag hier am Model und nicht in den einzelnen Diensten.
|
||||
*
|
||||
* Grenzen: Model-Events feuern weder bei Query-Builder-Massenupdates noch bei
|
||||
* `saveQuietly()`. Das Löschen braucht keinen Eintrag, die Rückrechnung wertet
|
||||
* dafür `deleted_at` aus.
|
||||
*/
|
||||
protected function logStatusTransition(): void
|
||||
{
|
||||
if (! $this->wasChanged('active')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->active) {
|
||||
$action = UserAboStatusHistory::ACTION_REACTIVATED;
|
||||
} else {
|
||||
// Status 4 = gekündigt; jede andere Abschaltung ist eine Deaktivierung.
|
||||
$action = (int) $this->status === 4
|
||||
? UserAboStatusHistory::ACTION_CANCELLED
|
||||
: UserAboStatusHistory::ACTION_DEACTIVATED;
|
||||
}
|
||||
|
||||
UserAboStatusHistory::create([
|
||||
'user_abo_id' => $this->id,
|
||||
'action' => $action,
|
||||
'view' => static::$statusLogView,
|
||||
'actor_user_id' => \Auth::id(),
|
||||
]);
|
||||
}
|
||||
|
||||
protected $casts = [
|
||||
|
|
@ -196,6 +263,36 @@ class UserAbo extends Model
|
|||
->whereNotIn('status', [4, 5, 6]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Abos mit noch offener Ausführung im Fenster von heute bis $windowEnd —
|
||||
* gemeinsame Grundlage aller Prognosen (Payline-Kachel, persönliche
|
||||
* Terminvorschau, Team-Übersicht).
|
||||
*
|
||||
* A13: Ein Abo mit `next_date` = heute galt bisher ab Mitternacht als
|
||||
* erledigt (`next_date > heute 23:59:59`). Der Ausführungsjob läuft aber erst
|
||||
* um 04:00 — bis dahin fehlten die Punkte in der Prognose UND im Ist. Deshalb
|
||||
* zählt der heutige Liefertag mit, solange für das Abo heute noch keine
|
||||
* Ausführung angelegt wurde. Sobald sie existiert, stecken die Punkte im Ist;
|
||||
* die Bedingung verhindert also gerade die Doppelzählung.
|
||||
*/
|
||||
public function scopeOpenExecutionUntil(Builder $query, Carbon $today, Carbon $windowEnd): Builder
|
||||
{
|
||||
$nextDate = $query->qualifyColumn('next_date');
|
||||
|
||||
return $query
|
||||
->where($nextDate, '<=', $windowEnd)
|
||||
->where(function (Builder $open) use ($nextDate, $today) {
|
||||
$open
|
||||
->where($nextDate, '>', $today->copy()->endOfDay())
|
||||
->orWhere(fn (Builder $dueToday) => $dueToday
|
||||
->whereDate($nextDate, $today->toDateString())
|
||||
->whereDoesntHave('user_abo_orders', fn ($order) => $order
|
||||
->whereDate('user_abo_orders.created_at', $today->toDateString())
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Schränkt die Abfrage auf Abos ein, die im angegebenen Monat/Jahr hinzugekommen
|
||||
* sind (Eingangsmonat = start_date). Bei month=0 (oder außerhalb 1–12) bleibt die
|
||||
|
|
|
|||
|
|
@ -49,6 +49,14 @@ class AboRepository extends BaseRepository
|
|||
}
|
||||
|
||||
public function update($data)
|
||||
{
|
||||
// Der Kanal wird für den gesamten Schreibvorgang gesetzt, damit auch die
|
||||
// zentral am Model protokollierten Zustandswechsel wissen, aus welchem
|
||||
// Formular sie stammen ({@see UserAbo::logStatusTransition()}).
|
||||
return UserAbo::withStatusLogView($data['view'] ?? null, fn () => $this->updateWithinStatusLogView($data));
|
||||
}
|
||||
|
||||
private function updateWithinStatusLogView($data)
|
||||
{
|
||||
if (isset($data['action'])) {
|
||||
if ($data['action'] === 'abo_update_settings') {
|
||||
|
|
@ -83,6 +91,13 @@ class AboRepository extends BaseRepository
|
|||
|
||||
public function create($data) {}
|
||||
|
||||
/**
|
||||
* Prüft die Regeln für Kündigung, Deaktivierung und Reaktivierung und setzt den
|
||||
* neuen Zustand. Der Protokolleintrag entsteht nicht hier, sondern zentral in
|
||||
* {@see UserAbo::logStatusTransition()} — sonst fehlt er in jedem Schreibpfad,
|
||||
* der nicht über dieses Formular läuft. Nur die Pause protokolliert selbst, weil
|
||||
* sie den Aktiv-Zustand nicht ändert und die Datumsverschiebung mitführt.
|
||||
*/
|
||||
private function updateStatus($data)
|
||||
{
|
||||
$isAdminUpdate = $this->isAdminUpdate($data);
|
||||
|
|
@ -114,12 +129,18 @@ class AboRepository extends BaseRepository
|
|||
$this->model->cancel_date = now();
|
||||
$this->model->save();
|
||||
|
||||
$this->logStatusHistory(UserAboStatusHistory::ACTION_CANCELLED, $data);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$active = (isset($data['abo_is_active']) && $data['abo_is_active']) ? true : false;
|
||||
// A01: Im Selbstbedienungspfad ist der Aktiv-Zustand kein Eingabefeld. Das
|
||||
// Formular spiegelt ihn nur in einem versteckten Feld zurück; würde er von
|
||||
// dort übernommen, liesse sich die Mindestlaufzeit durch dauerhafte
|
||||
// Deaktivierung umgehen und der Ein-Zyklus-Pausenpfad samt seiner Sperren
|
||||
// ganz überspringen. Zustandswechsel laufen hier ausschliesslich über die
|
||||
// dafür zugelassenen Aktionen Pause und Kündigung.
|
||||
$active = $isAdminUpdate
|
||||
? (bool) ($data['abo_is_active'] ?? false)
|
||||
: (bool) $this->model->active;
|
||||
// Sperre: 3 Tage vor Ausführung kann nicht mehr pausiert werden
|
||||
if (! $isAdminUpdate && $this->model->active && ! $active && $this->model->next_date) {
|
||||
$daysUntil = (int) now()->diffInDays(Carbon::parse($this->model->next_date), false);
|
||||
|
|
@ -135,8 +156,6 @@ class AboRepository extends BaseRepository
|
|||
$this->model->status = 6; // inactive
|
||||
$this->model->active = false;
|
||||
$this->model->save();
|
||||
|
||||
$this->logStatusHistory(UserAboStatusHistory::ACTION_DEACTIVATED, $data);
|
||||
}
|
||||
}
|
||||
if (! $this->model->active && $active) {
|
||||
|
|
@ -144,8 +163,6 @@ class AboRepository extends BaseRepository
|
|||
$this->model->status = 2; // okay
|
||||
$this->model->active = true;
|
||||
$this->model->save();
|
||||
|
||||
$this->logStatusHistory(UserAboStatusHistory::ACTION_REACTIVATED, $data);
|
||||
}
|
||||
}
|
||||
$this->model->active = $active;
|
||||
|
|
|
|||
|
|
@ -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