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:
Kevin 2026-09-10 11:19:38 +00:00
parent 92344f7080
commit 5a38f4ac3e
31 changed files with 1931 additions and 241 deletions

View file

@ -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),

View file

@ -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', '', '', '', ''];

View file

@ -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

View file

@ -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;

View file

@ -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));

View file

@ -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)

View file

@ -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,

View file

@ -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)

View file

@ -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,
};
}

View file

@ -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;
}
}

View file

@ -49,7 +49,13 @@ return [
'payline_target_label' => 'Soll (Punkte pro Zyklus)',
'payline_actual_label' => 'Ist (im Monat abgerechnet)',
'payline_hint' => 'Payline = :lines Ebenen · :members Berater',
'payline_abo_counts' => ':team_abos Teamabos + :customer_abos Kundenabos in der Payline',
'payline_abo_counts' => ':team_abos Teamabos + :customer_abos Kundenabos in der Payline = :total Abos gesamt',
'abo_order_short' => '(Abo)',
'points_order_legend' => 'Bestellungen: blau = eigene Bestellung · grau = Kundenbestellung · türkis mit „(Abo)" = Abo-Lieferung.',
'customer_abo_forecast_title' => 'Punkte aus meinen Kundenabos zum 5. / 10. / 20. / 25.',
'customer_abo_forecast_hint' => 'Kumuliert: die Punkte aller eigenen Kundenabos, deren Lieferung bis zum jeweiligen Termin dieses Monats noch aussteht. Ohne dein eigenes Abo und ohne Team.',
'customer_abo_list_title' => 'Meine Kundenabos (:count)',
'customer_abo_list_search' => 'Kundenabos durchsuchen...',
'payline_none' => 'Noch keine Payline vorhanden (kein Provisionslevel erreicht).',
'abo_points_per_cycle' => 'Punkte pro Lieferung (Bestand)',
'abo_points_billed' => 'abgerechnet (:month)',

View file

@ -49,7 +49,9 @@ return [
'welcome_back' => 'Willkommen zurück',
'your_shop' => 'Dein Shop',
'commission_today' => 'Provision (Stand heute)',
'commission_unavailable' => 'Die Provisionsberechnung ist gerade nicht verfuegbar. Der Wert wird nachgetragen, sobald sie wieder laeuft.',
'payline_forecast_title' => 'Payline-Prognose zum 5. / 10. / 20. / 25.',
'payline_forecast_basis' => 'Basis: Paylinepunkte + persönliches Volumen',
'payline_forecast_hint' => 'Basis: aktueller Kundenumsatz + Teamumsatz (Payline). Dazu kommen kumuliert die bis zum jeweiligen Termin noch anstehenden Abo-Punkte deiner Payline (inkl. deiner eigenen Abos und Kundenabos). Prognose: Pausen, Kündigungen oder fehlgeschlagene Zahlungen können den Wert verändern.',
'forecast_day' => 'Prognose zum :date',
'forecast_upcoming' => 'Abo-Punkte offen',
@ -75,19 +77,24 @@ return [
'team_partner_total' => 'Teampartner gesamt',
'payline_partner_count' => 'Teampartner in der Payline',
'payline_abo_count' => 'Abos in der Payline',
'payline_career_fallback_hint' => 'Gezählt nach den Ebenen deines Karriere-Levels: deine Qualifikation für diesen Monat steht noch aus. Sobald sie feststeht, zählt die Kachel nach den Ebenen der Qualifikationsstufe.',
'level_current' => 'Qualifikation in diesem Monat',
'level_career' => 'Karriere-Level',
'level_next_missing' => 'Für die Qualifikation als :level in diesem Monat fehlen dir noch :points Payline-Punkte.',
'level_next_missing_personal' => 'Zusätzlich fehlen :points Punkte persönliches Volumen.',
'level_not_qualified_yet' => 'Noch nicht qualifiziert',
'level_next_missing_personal_only' => 'Für die Qualifikation als :level in diesem Monat fehlen dir noch :points Punkte persönliches Volumen.',
'level_top_reached' => 'Höchstes Level erreicht.',
'level_hint' => 'Monatliche Qualifikationsstufe aus dem Live-Lauf. Payline-Punkte = Teampunkte deiner bezahlten Ebenen inkl. deines Kundenpunkte-Überschusses.',
'forecast_level_title' => 'Level & Provision laut Prognose (Stand 25.)',
'forecast_unverbindlich' => 'unverbindlich',
'forecast_level_disclaimer' => 'Schätzung auf Basis der heutigen Punkte plus der noch offenen Abo-Lieferungen. Rang-/Strukturänderungen im Team sind nicht berücksichtigt - verbindlich ist erst der Monatsabschluss.',
'forecast_level_needs_qualification' => 'Level- und Provisions-Prognose erscheint, sobald deine Qualifikation für diesen Monat feststeht — die Provisionssätze hängen an der Qualifikationsstufe.',
'forecast_level_reached' => 'Laut Prognose erreichst du das nächste Level :level - sofern dein persönliches Volumen von :volume Punkten erreicht wird.',
'forecast_level_missing' => 'Laut Prognose fehlen zum nächsten Level :level noch :points Payline-Punkte.',
'forecast_level_missing_personal' => 'Zusätzlich fehlen :points Punkte persönliches Volumen.',
'forecast_commission' => 'Voraussichtliche Provision laut Prognose: :amount (auf Basis Level :level).',
'forecast_commission_no_level' => 'Voraussichtliche Provision laut Prognose: :amount. Team-Provision kommt hinzu, sobald die erste Qualifikationsstufe erreicht ist.',
// N6: "Die besten Ergebnisse des Monats" (teamintern)
'top_results_title' => 'Die besten Ergebnisse des Monats (Team)',

View file

@ -175,7 +175,13 @@ return [
'payline_target_label' => 'Target (points per cycle)',
'payline_actual_label' => 'Actual (billed this month)',
'payline_hint' => 'Payline = :lines levels · :members consultants',
'payline_abo_counts' => ':team_abos team subscriptions + :customer_abos customer subscriptions in the payline',
'payline_abo_counts' => ':team_abos team subscriptions + :customer_abos customer subscriptions in the payline = :total subscriptions in total',
'abo_order_short' => '(subscr.)',
'points_order_legend' => 'Orders: blue = your own order · grey = customer order · turquoise with "(subscr.)" = subscription delivery.',
'customer_abo_forecast_title' => 'Points from my customer subscriptions for the 5th / 10th / 20th / 25th',
'customer_abo_forecast_hint' => 'Cumulative: the points of all your own customer subscriptions whose delivery is still due by the respective date this month. Without your own subscription and without your team.',
'customer_abo_list_title' => 'My customer subscriptions (:count)',
'customer_abo_list_search' => 'Search customer subscriptions...',
'payline_none' => 'No payline yet (no commission level reached).',
'abo_points_per_cycle' => 'Points per delivery (current stock)',
'abo_points_billed' => 'billed (:month)',

View file

@ -48,7 +48,9 @@ return [
'welcome_back' => 'welcome back',
'your_shop' => 'your shop',
'commission_today' => 'Commission (as of today)',
'commission_unavailable' => 'The commission calculation is currently unavailable. The value will appear once it runs again.',
'payline_forecast_title' => 'Payline forecast for the 5th / 10th / 20th / 25th',
'payline_forecast_basis' => 'Basis: payline points + personal volume',
'payline_forecast_hint' => 'Basis: current customer turnover + team turnover (payline). Plus, cumulatively, the subscription points of your payline still due up to the respective date (incl. your own subscriptions and customer subscriptions). Forecast: pauses, cancellations or failed payments can change the value.',
'forecast_day' => 'Forecast for :date',
'forecast_upcoming' => 'subscription points due',
@ -75,19 +77,24 @@ return [
'team_partner_total' => 'Team partners total',
'payline_partner_count' => 'Team partners in the payline',
'payline_abo_count' => 'Subscriptions in the payline',
'payline_career_fallback_hint' => 'Counted across the levels of your career rank: your qualification for this month is still pending. Once it is set, the tile counts across the levels of the qualification rank.',
'level_current' => 'Qualification this month',
'level_career' => 'Career level',
'level_next_missing' => 'You still need :points payline points to qualify as :level this month.',
'level_next_missing_personal' => 'Additionally :points points of personal volume are missing.',
'level_not_qualified_yet' => 'Not yet qualified',
'level_next_missing_personal_only' => 'You still need :points points of personal volume to qualify as :level this month.',
'level_top_reached' => 'Highest level reached.',
'level_hint' => 'Monthly qualification level from the live run. Payline points = team points of your paid levels including your customer points surplus.',
'forecast_level_title' => 'Level & commission according to forecast (as of the 25th)',
'forecast_unverbindlich' => 'non-binding',
'forecast_level_disclaimer' => 'Estimate based on today\'s points plus the still open subscription deliveries. Rank/structure changes in the team are not taken into account - only the monthly closing is binding.',
'forecast_level_needs_qualification' => 'The level and commission forecast appears as soon as your qualification for this month is set — the commission rates depend on the qualification rank.',
'forecast_level_reached' => 'According to the forecast you will reach the next level :level - provided your personal volume of :volume points is reached.',
'forecast_level_missing' => 'According to the forecast, :points payline points are still missing for the next level :level.',
'forecast_level_missing_personal' => 'Additionally :points points of personal volume are missing.',
'forecast_commission' => 'Expected commission according to forecast: :amount (based on level :level).',
'forecast_commission_no_level' => 'Projected commission: :amount. Team commission is added once the first qualification level is reached.',
// N6: "Best results of the month" (team-internal)
'top_results_title' => 'Best results of the month (team)',

View file

@ -158,7 +158,13 @@ return [
'payline_target_label' => 'Objetivo (puntos por ciclo)',
'payline_actual_label' => 'Real (facturado este mes)',
'payline_hint' => 'Payline = :lines niveles · :members asesores',
'payline_abo_counts' => ':team_abos suscripciones de asesores + :customer_abos suscripciones de clientes en la payline',
'payline_abo_counts' => ':team_abos suscripciones de equipo + :customer_abos suscripciones de clientes en la payline = :total suscripciones en total',
'abo_order_short' => '(susc.)',
'points_order_legend' => 'Pedidos: azul = pedido propio · gris = pedido de cliente · turquesa con «(susc.)» = entrega de suscripción.',
'customer_abo_forecast_title' => 'Puntos de mis suscripciones de clientes para el 5 / 10 / 20 / 25',
'customer_abo_forecast_hint' => 'Acumulado: los puntos de todas tus suscripciones de clientes cuya entrega aún está pendiente hasta la fecha correspondiente de este mes. Sin tu propia suscripción y sin el equipo.',
'customer_abo_list_title' => 'Mis suscripciones de clientes (:count)',
'customer_abo_list_search' => 'Buscar suscripciones de clientes...',
'payline_none' => 'Aún no hay payline (no se ha alcanzado ningún nivel de comisión).',
'abo_points_per_cycle' => 'Puntos por entrega (cartera actual)',
'abo_points_billed' => 'facturado (:month)',

View file

@ -48,7 +48,9 @@ return [
'welcome_back' => 'bienvenido de nuevo',
'your_shop' => 'tu tienda',
'commission_today' => 'Comisión (a día de hoy)',
'commission_unavailable' => 'El calculo de comisiones no esta disponible en este momento. El valor aparecera cuando vuelva a ejecutarse.',
'payline_forecast_title' => 'Pronóstico de payline para el 5 / 10 / 20 / 25',
'payline_forecast_basis' => 'Base: puntos de payline + volumen personal',
'payline_forecast_hint' => 'Base: volumen de clientes actual + volumen del equipo (payline). Más, de forma acumulada, los puntos de suscripción de tu payline pendientes hasta la fecha correspondiente (incl. tus propias suscripciones y las de tus clientes). Pronóstico: pausas, cancelaciones o pagos fallidos pueden cambiar el valor.',
'forecast_day' => 'Pronóstico para el :date',
'forecast_upcoming' => 'puntos de suscripción pendientes',
@ -75,19 +77,24 @@ return [
'team_partner_total' => 'Socios en total',
'payline_partner_count' => 'Socios en la payline',
'payline_abo_count' => 'Suscripciones en la payline',
'payline_career_fallback_hint' => 'Contado según los niveles de tu nivel de carrera: tu calificación de este mes aún está pendiente. En cuanto esté fijada, la tarjeta contará según los niveles del nivel de calificación.',
'level_current' => 'Calificación de este mes',
'level_career' => 'Nivel de carrera',
'level_next_missing' => 'Para calificar como :level este mes te faltan :points puntos de payline.',
'level_next_missing_personal' => 'Además faltan :points puntos de volumen personal.',
'level_not_qualified_yet' => 'Aún no calificado',
'level_next_missing_personal_only' => 'Para calificar como :level este mes te faltan :points puntos de volumen personal.',
'level_top_reached' => 'Nivel más alto alcanzado.',
'level_hint' => 'Nivel de calificación mensual del cálculo en vivo. Puntos de payline = puntos de equipo de tus niveles pagados incluyendo tu excedente de puntos de clientes.',
'forecast_level_title' => 'Nivel y comisión según la previsión (al día 25)',
'forecast_unverbindlich' => 'sin compromiso',
'forecast_level_disclaimer' => 'Estimación basada en los puntos de hoy más las entregas de suscripción pendientes. No se consideran cambios de rango/estructura en el equipo - solo el cierre mensual es vinculante.',
'forecast_level_needs_qualification' => 'La previsión de nivel y comisión aparece en cuanto se fija tu calificación de este mes: los porcentajes de comisión dependen del nivel de calificación.',
'forecast_level_reached' => 'Según la previsión alcanzarás el siguiente nivel :level - siempre que se alcance tu volumen personal de :volume puntos.',
'forecast_level_missing' => 'Según la previsión faltan :points puntos de payline para el siguiente nivel :level.',
'forecast_level_missing_personal' => 'Además faltan :points puntos de volumen personal.',
'forecast_commission' => 'Comisión prevista según la previsión: :amount (según nivel :level).',
'forecast_commission_no_level' => 'Comision prevista segun el pronostico: :amount. La comision de equipo se anade en cuanto se alcanza el primer nivel de cualificacion.',
// N6: "Los mejores resultados del mes" (interno del equipo)
'top_results_title' => 'Los mejores resultados del mes (equipo)',

View file

@ -49,7 +49,13 @@ return [
'payline_target_label' => 'Objectif (points par cycle)',
'payline_actual_label' => 'Réel (facturé ce mois-ci)',
'payline_hint' => 'Payline = :lines niveaux · :members conseillers',
'payline_abo_counts' => ':team_abos abonnements conseillers + :customer_abos abonnements clients dans la payline',
'payline_abo_counts' => ':team_abos abonnements équipe + :customer_abos abonnements clients dans la payline = :total abonnements au total',
'abo_order_short' => '(abo)',
'points_order_legend' => 'Commandes : bleu = commande personnelle · gris = commande client · turquoise avec « (abo) » = livraison d’abonnement.',
'customer_abo_forecast_title' => 'Points de mes abonnements clients pour le 5 / 10 / 20 / 25',
'customer_abo_forecast_hint' => 'Cumulé : les points de tous tes abonnements clients dont la livraison est encore à venir jusqu’à la date concernée ce mois-ci. Sans ton propre abonnement et sans l’équipe.',
'customer_abo_list_title' => 'Mes abonnements clients (:count)',
'customer_abo_list_search' => 'Rechercher des abonnements clients...',
'payline_none' => 'Pas encore de payline (aucun niveau de commission atteint).',
'abo_points_per_cycle' => 'Points par livraison (portefeuille actuel)',
'abo_points_billed' => 'facturé (:month)',

View file

@ -49,7 +49,9 @@ return [
'welcome_back' => 'Bon retour',
'your_shop' => 'Votre boutique',
'commission_today' => 'Commission (à ce jour)',
'commission_unavailable' => 'Le calcul des commissions n\'est pas disponible actuellement. La valeur apparaitra des qu\'il fonctionnera de nouveau.',
'payline_forecast_title' => 'Prévision payline pour le 5 / 10 / 20 / 25',
'payline_forecast_basis' => 'Base : points payline + volume personnel',
'payline_forecast_hint' => 'Base : chiffre d’affaires clients actuel + chiffre d’affaires équipe (payline). S’y ajoutent, de manière cumulée, les points d’abonnement de ta payline encore à venir jusqu’à la date concernée (y compris tes propres abonnements et abonnements clients). Prévision : pauses, résiliations ou paiements échoués peuvent modifier la valeur.',
'forecast_day' => 'Prévision pour le :date',
'forecast_upcoming' => 'points d’abonnement à venir',
@ -75,19 +77,24 @@ return [
'team_partner_total' => 'Partenaires au total',
'payline_partner_count' => 'Partenaires dans la payline',
'payline_abo_count' => 'Abonnements dans la payline',
'payline_career_fallback_hint' => 'Compté sur les niveaux de ton niveau de carrière : ta qualification pour ce mois n\'est pas encore établie. Dès qu\'elle le sera, la tuile comptera sur les niveaux du niveau de qualification.',
'level_current' => 'Qualification de ce mois',
'level_career' => 'Niveau de carrière',
'level_next_missing' => 'Pour te qualifier comme :level ce mois-ci, il te manque encore :points points de payline.',
'level_next_missing_personal' => 'Il manque en plus :points points de volume personnel.',
'level_not_qualified_yet' => 'Pas encore qualifié',
'level_next_missing_personal_only' => 'Pour te qualifier comme :level ce mois-ci, il te manque encore :points points de volume personnel.',
'level_top_reached' => 'Niveau le plus élevé atteint.',
'level_hint' => 'Niveau de qualification mensuel issu du calcul en direct. Points de payline = points d’équipe de tes niveaux payés, y compris ton excédent de points clients.',
'forecast_level_title' => 'Niveau et commission selon la prévision (au 25)',
'forecast_unverbindlich' => 'sans engagement',
'forecast_level_disclaimer' => 'Estimation basée sur les points actuels plus les livraisons d’abonnement encore ouvertes. Les changements de rang/structure dans l’équipe ne sont pas pris en compte - seule la clôture mensuelle fait foi.',
'forecast_level_needs_qualification' => 'La prévision de niveau et de commission apparaît dès que ta qualification pour ce mois est établie : les taux de commission dépendent du niveau de qualification.',
'forecast_level_reached' => 'Selon la prévision, tu atteindras le niveau suivant :level - à condition que ton volume personnel de :volume points soit atteint.',
'forecast_level_missing' => 'Selon la prévision, il manque encore :points points de payline pour le niveau suivant :level.',
'forecast_level_missing_personal' => 'Il manque en plus :points points de volume personnel.',
'forecast_commission' => 'Commission prévisionnelle selon la prévision : :amount (sur la base du niveau :level).',
'forecast_commission_no_level' => 'Commission prevue selon la prevision : :amount. La commission d\'equipe s\'ajoute des que le premier niveau de qualification est atteint.',
// N6 : « Les meilleurs résultats du mois » (interne à l'équipe)
'top_results_title' => 'Les meilleurs résultats du mois (équipe)',

View file

@ -0,0 +1,143 @@
{{-- Sortier- und Suchverhalten für einfache Tabellen ohne Serverpaging.
Erwartet:
$tableId id der <table>
$searchId id eines optionalen Suchfeldes (leer lassen, wenn keins da ist)
$minWidth optionale Mindestbreite der Tabelle (Default 980px)
Sortierbare Spalten bekommen `data-sortable="true"` und optional
`data-sort-type="number|date"`. Für große Listen ist das nicht gedacht —
dort gehört Yajra-Datatables hin.
Sortierwerte (A14): Zellen mit Zahlen, Datum oder mehrzeiligem Inhalt tragen
`data-sort-value` mit dem UNFORMATIERTEN Wert — Zahl mit Punkt als
Dezimaltrenner, Datum als `Y-m-d`. Der Zellentext ist lokalisiert
(`Util::_thousands_separator()` und `formatDate()` schalten bei `en` auf
`1,000.00` und `Y-m-d` um) und taugt deshalb nicht als Sortiergrundlage:
der Datumsparser erkennt nur `dd.mm.yyyy`, und der Zahlenparser macht aus
`1,000.00` eine 1. Ohne Attribut wird weiterhin der Text gelesen — das
stimmt nur im deutschen Format. --}}
@php
$sortableMinWidth = $minWidth ?? '980px';
$sortableSearchId = $searchId ?? null;
@endphp
<style>
#{{ $tableId }} {
min-width: {{ $sortableMinWidth }};
}
#{{ $tableId }} th,
#{{ $tableId }} td {
vertical-align: middle;
}
#{{ $tableId }} th[data-sortable="true"] {
white-space: nowrap;
}
#{{ $tableId }} .sortable-label {
align-items: center;
display: inline-flex;
gap: .25rem;
white-space: nowrap;
width: 100%;
}
#{{ $tableId }} .sort-icon {
color: #adb5bd;
flex: 0 0 auto;
font-size: .875rem;
line-height: 1;
}
#{{ $tableId }} th[data-sort-direction="asc"] .sort-icon,
#{{ $tableId }} th[data-sort-direction="desc"] .sort-icon {
color: #4e73df;
}
</style>
<script>
$(document).ready(function() {
var tableSelector = '#{{ $tableId }}';
var searchSelector = @json($sortableSearchId ? '#'.$sortableSearchId : null);
if (searchSelector) {
$(searchSelector).on('keyup search', function() {
var search = $(this).val().toLowerCase();
$(tableSelector + ' tbody tr').each(function() {
var rowText = $(this).text().toLowerCase();
$(this).toggle(rowText.indexOf(search) !== -1);
});
});
}
$(tableSelector + ' th[data-sortable="true"]').css('cursor', 'pointer').on('click', function() {
var $header = $(this);
var columnIndex = $header.index();
var sortType = $header.data('sort-type') || 'text';
var direction = $header.data('sort-direction') === 'asc' ? 'desc' : 'asc';
var rows = $(tableSelector + ' tbody tr').get();
rows.sort(function(a, b) {
var aValue = getSortValue($(a).children('td').eq(columnIndex), sortType);
var bValue = getSortValue($(b).children('td').eq(columnIndex), sortType);
var result;
if (sortType === 'number' || sortType === 'date') {
result = aValue < bValue ? -1 : (aValue > bValue ? 1 : 0);
} else {
// Umlaute und Akzente gehören alphabetisch einsortiert, nicht
// hinter das Z — das leistet nur ein Sprachvergleich.
result = aValue.localeCompare(bValue, undefined, {
numeric: true,
sensitivity: 'base'
});
}
return direction === 'asc' ? result : -result;
});
$(tableSelector + ' th[data-sortable="true"]')
.removeData('sort-direction')
.removeAttr('data-sort-direction');
$header.data('sort-direction', direction).attr('data-sort-direction', direction);
$.each(rows, function(index, row) {
$(tableSelector + ' tbody').append(row);
});
});
function getSortValue($cell, sortType) {
var raw = $cell.attr('data-sort-value');
var hasRaw = typeof raw !== 'undefined';
var value = $.trim(hasRaw ? raw : $cell.text());
if (sortType === 'number') {
// Leer heißt "kein Wert" und gehört ans Ende, nicht zwischen die
// Stornos: eine 0 stünde sonst mitten in negativen Punkteständen.
if (value === '') {
return Number.NEGATIVE_INFINITY;
}
return hasRaw
? (parseFloat(value) || 0)
: (parseFloat(value.replace(/\./g, '').replace(',', '.').replace(/[^\d.-]/g, '')) || 0);
}
if (sortType === 'date') {
if (hasRaw) {
var iso = value.match(/^(\d{4})-(\d{2})-(\d{2})/);
return iso ? new Date(iso[1], iso[2] - 1, iso[3]).getTime() : Number.NEGATIVE_INFINITY;
}
var parts = value.match(/(\d{2})\.(\d{2})\.(\d{4})/);
return parts ? new Date(parts[3], parts[2] - 1, parts[1]).getTime() : Number.NEGATIVE_INFINITY;
}
return value.toLowerCase();
}
});
</script>

View file

@ -20,6 +20,15 @@
$fmt = fn ($value) => number_format((float) $value, 0, ',', '.');
// Solange die Monatsqualifikation aussteht, zaehlen die beiden
// Payline-Struktur-Kacheln nach den Ebenen des Karriere-Levels — das
// wird an der Kachel ausgewiesen, damit die Zahl einordenbar bleibt.
$paylineFallbackNote = ! empty($s['payline_is_career_fallback'])
? __('home.payline_career_fallback_hint')
: null;
$commissionFailed = (bool) ($s['commission']['failed'] ?? false);
$clusters = [
[
'title' => __('home.stats_group_turnover'),
@ -39,9 +48,15 @@
],
[
'label' => __('home.commission_today'),
'value' => number_format((float) ($s['commission']['total'] ?? 0), 2, ',', '.') . ' €',
'icon' => 'ion-md-cash', 'color' => 'warning', 'link' => null, 'new' => null,
'live' => (bool) ($s['commission']['is_live'] ?? false),
// Eine fehlgeschlagene Berechnung darf nicht wie ein Nullmonat aussehen.
'value' => $commissionFailed
? '—'
: number_format((float) ($s['commission']['total'] ?? 0), 2, ',', '.') . ' €',
'icon' => 'ion-md-cash',
'color' => $commissionFailed ? 'secondary' : 'warning',
'link' => null, 'new' => null,
'live' => ! $commissionFailed && (bool) ($s['commission']['is_live'] ?? false),
'note' => $commissionFailed ? __('home.commission_unavailable') : null,
],
],
],
@ -75,11 +90,13 @@
[
'label' => __('home.payline_partner_count'), 'value' => $fmt($s['payline_member_count'] ?? 0),
'icon' => 'ion-md-podium', 'color' => 'success', 'link' => null, 'new' => null, 'live' => false,
'note' => $paylineFallbackNote,
],
[
'label' => __('home.payline_abo_count'), 'value' => $fmt($s['payline_abo_count'] ?? 0),
'icon' => 'ion-md-repeat', 'color' => 'success', 'link' => $teamShow,
'new' => $s['payline_abos_new'] ?? 0, 'live' => false,
'note' => $paylineFallbackNote,
],
],
],
@ -160,6 +177,10 @@
@if ($tile['link'])
<i class="ion ion-md-open text-primary ml-1" style="font-size: .8rem;"></i>
@endif
@if (! empty($tile['note']))
<i class="ion ion-md-information-circle-outline text-warning ml-1"
style="font-size: .9rem;" title="{{ $tile['note'] }}"></i>
@endif
</div>
<div class="font-weight-bold text-large">
{{ $tile['value'] }}
@ -195,20 +216,34 @@
<span class="ion ion-md-information-circle-outline ml-1" title="{{ __('home.level_hint') }}"></span>
</h6>
<div class="d-flex align-items-center flex-wrap mb-2">
<span class="badge badge-primary mr-3 mb-2" style="font-size: 1rem; padding: .5rem .9rem;">
{{ $level['name'] }}
@php($isQualified = $level['is_qualified'] ?? true)
<span class="badge {{ $isQualified ? 'badge-primary' : 'badge-secondary' }} mr-3 mb-2"
style="font-size: 1rem; padding: .5rem .9rem;">
{{ $isQualified ? $level['name'] : __('home.level_not_qualified_yet') }}
</span>
<div class="mb-2">
@if ($level['next_name'] !== null && $level['next_name'] !== '')
<div>
{!! __('home.level_next_missing', [
'level' => '<strong>'.e($level['next_name']).'</strong>',
'points' => '<strong>'.$fmt($level['missing_pp']).'</strong>',
]) !!}
</div>
@if ($level['missing_personal'] > 0)
<div class="text-muted small">
{{ __('home.level_next_missing_personal', ['points' => $fmt($level['missing_personal'])]) }}
{{-- Ohne Monatsquali fehlen typischerweise nur persoenliche Punkte
(das erste Level hat qual_pp = 0) — dann darf hier kein Satz
mit "0 Payline-Punkte" stehen. --}}
@if ($level['missing_pp'] > 0)
<div>
{!! __('home.level_next_missing', [
'level' => '<strong>'.e($level['next_name']).'</strong>',
'points' => '<strong>'.$fmt($level['missing_pp']).'</strong>',
]) !!}
</div>
@if ($level['missing_personal'] > 0)
<div class="text-muted small">
{{ __('home.level_next_missing_personal', ['points' => $fmt($level['missing_personal'])]) }}
</div>
@endif
@elseif ($level['missing_personal'] > 0)
<div>
{!! __('home.level_next_missing_personal_only', [
'level' => '<strong>'.e($level['next_name']).'</strong>',
'points' => '<strong>'.$fmt($level['missing_personal']).'</strong>',
]) !!}
</div>
@endif
@else
@ -228,6 +263,9 @@
<span class="badge badge-pill badge-info ml-1"
title="{{ __('home.payline_forecast_hint') }}">Live</span>
</h6>
{{-- Woraus die Basis der vier Kacheln besteht (Kundenwunsch 2026-09-07):
HomeController uebergibt customer_points + team_payline_points. --}}
<div class="text-muted small mb-3 mt-n2">{{ __('home.payline_forecast_basis') }}</div>
<div class="row">
@foreach ($s['payline_forecast']['days'] as $forecastDay)
<div class="col-sm-6 col-lg-3 mb-3">
@ -281,12 +319,28 @@
@endif
@endif
<div>
{!! __('home.forecast_commission', [
'amount' => '<strong>'.number_format((float) $lf['estimated_commission'], 2, ',', '.').' €</strong>',
'level' => e($lf['estimated_level_name']),
]) !!}
@if ($lf['estimated_level_name'] !== '')
{!! __('home.forecast_commission', [
'amount' => '<strong>'.number_format((float) $lf['estimated_commission'], 2, ',', '.').' €</strong>',
'level' => e($lf['estimated_level_name']),
]) !!}
@else
{{-- Noch keine Stufe erreicht: die Prognose nennt einen Betrag,
aber keine Stufe, auf deren Saetzen er beruht. --}}
{!! __('home.forecast_commission_no_level', [
'amount' => '<strong>'.number_format((float) $lf['estimated_commission'], 2, ',', '.').' €</strong>',
]) !!}
@endif
</div>
</div>
@elseif (! empty($s['level']) && ! ($s['level']['is_qualified'] ?? true))
{{-- Ohne Qualifikationsstufe gibt es keine Provisionssaetze, mit denen
sich eine Prognose rechnen liesse. Statt den Block wortlos
wegzulassen, wird der Grund genannt. --}}
<div class="alert alert-light border mb-0 text-muted small">
<i class="ion ion-md-information-circle-outline mr-1"></i>
{{ __('home.forecast_level_needs_qualification') }}
</div>
@endif
@endif
</div>

View file

@ -0,0 +1,110 @@
{{-- Eigene Kundenabos als sortierbare Liste (Kundenwunsch 2026-09-07: die
Kartenansicht war bei vielen Abos unübersichtlich). Spalten und Sortierung
nach ihrer Vorgabe: Kunde, Liefertag, Abo-Punkte, nächste Ausführung,
Startdatum, Ausführungen.
Erwartet $listAbos (Collection<UserAbo>) und $customerAboForecast|null. --}}
@if (! empty($customerAboForecast['days']))
<div class="card mb-4">
<div class="card-body">
<h6 class="text-muted text-uppercase font-weight-bold small mb-1">
{{ __('abo.customer_abo_forecast_title') }}
</h6>
<div class="text-muted small mb-3">{{ __('abo.customer_abo_forecast_hint') }}</div>
<div class="row">
@foreach ($customerAboForecast['days'] as $forecastDay)
<div class="col-sm-6 col-lg-3 mb-2">
<div class="card h-100 {{ $forecastDay['is_past'] ? 'bg-light' : '' }}">
<div class="card-body py-3 text-center">
<div class="text-muted small">
{{ __('home.forecast_day', ['date' => $forecastDay['date']]) }}
</div>
<div class="font-weight-bold text-large">
{{ \App\Services\Util::formatNumber($forecastDay['total']) }}
</div>
<div class="small {{ $forecastDay['upcoming'] > 0 ? 'text-success' : 'text-muted' }}">
{{ $forecastDay['is_past'] && $forecastDay['upcoming'] <= 0 ? __('home.forecast_done') : __('navigation.points') }}
</div>
</div>
</div>
</div>
@endforeach
</div>
</div>
</div>
@endif
<div class="card mb-4">
<div class="card-body pb-2">
<div class="row align-items-center">
<div class="col-md-6">
<h5 class="mb-0">{{ __('abo.customer_abo_list_title', ['count' => $listAbos->count()]) }}</h5>
</div>
<div class="col-md-6 mt-2 mt-md-0">
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><span class="ion ion-ios-search"></span></span>
</div>
<input type="search" id="customer-abo-search" class="form-control"
placeholder="{{ __('abo.customer_abo_list_search') }}">
</div>
</div>
</div>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0" id="customer-abo-table">
<thead>
<tr>
<th data-sortable="true"><span class="sortable-label">{{ __('tables.customer') }} <i class="ion ion-ios-swap sort-icon"></i></span></th>
<th data-sortable="true" data-sort-type="number"><span class="sortable-label">{{ __('tables.abo_delivery_day') }} <i class="ion ion-ios-swap sort-icon"></i></span></th>
<th class="text-right" data-sortable="true" data-sort-type="number"><span class="sortable-label justify-content-end">{{ __('navigation.points') }} <i class="ion ion-ios-swap sort-icon"></i></span></th>
<th data-sortable="true" data-sort-type="date"><span class="sortable-label">{{ __('tables.next_date') }} <i class="ion ion-ios-swap sort-icon"></i></span></th>
<th data-sortable="true" data-sort-type="date"><span class="sortable-label">{{ __('tables.start_date') }} <i class="ion ion-ios-swap sort-icon"></i></span></th>
<th class="text-right" data-sortable="true" data-sort-type="number"><span class="sortable-label justify-content-end">{{ __('tables.abo_delivery') }} <i class="ion ion-ios-swap sort-icon"></i></span></th>
<th data-sortable="true"><span class="sortable-label">{{ __('tables.status') }} <i class="ion ion-ios-swap sort-icon"></i></span></th>
<th></th>
</tr>
</thead>
<tbody>
@foreach ($listAbos as $listAbo)
<tr>
@php
// Sortiert wird über data-sort-value, nicht über den
// angezeigten Text — der ist lokalisiert (A14). Der Name
// steht zusätzlich getrennt da, weil die Zelle auch die
// E-Mail enthält und sonst beides zusammen sortiert würde.
$listAboName = $listAbo->shopping_user
? trim($listAbo->shopping_user->billing_firstname.' '.$listAbo->shopping_user->billing_lastname)
: (string) $listAbo->email;
@endphp
<td data-sort-value="{{ $listAboName }}">
<div class="font-weight-semibold">
{{ $listAboName }}
</div>
@if ($listAbo->shopping_user)
<div class="text-muted small">{{ $listAbo->shopping_user->billing_email }}</div>
@endif
</td>
<td data-sort-value="{{ (int) $listAbo->abo_interval }}">{{ \App\Services\HTMLHelper::getAboStrLang($listAbo->abo_interval) }}</td>
<td class="text-right font-weight-bold text-primary" data-sort-value="{{ $listAbo->getTotalPoints() }}">{{ $listAbo->getFormattedTotalPoints() }}</td>
<td data-sort-value="{{ $listAbo->getRawOriginal('next_date') }}">{{ $listAbo->next_date }}</td>
<td data-sort-value="{{ $listAbo->getRawOriginal('start_date') }}">{{ $listAbo->start_date }}</td>
<td class="text-right" data-sort-value="{{ $listAbo->getCountPaidOrders() }}">{{ $listAbo->getCountPaidOrders() }}</td>
<td data-sort-value="{{ $listAbo->getStatusType() }}">{!! $listAbo->getStatusFormated() !!} {!! get_active_badge($listAbo->active) !!}</td>
<td class="text-right">
<a href="{{ route('user_abos_detail', ['ot', $listAbo->id]) }}" class="btn btn-sm btn-secondary">
<span class="fa fa-edit"></span> {{ __('abo.abo_details') }}
</a>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@include('components._sortable_table', [
'tableId' => 'customer-abo-table',
'searchId' => 'customer-abo-search',
'minWidth' => '900px',
])

View file

@ -100,9 +100,10 @@
<th class="text-right" data-sortable="true" data-sort-type="number" style="background-color:#d4edda;"><span class="sortable-label justify-content-end">Kundenpunkte <i class="ion ion-ios-swap sort-icon"></i></span></th>
<th class="text-right" data-sortable="true" data-sort-type="number"><span class="sortable-label justify-content-end">Gesamtpunkte <i class="ion ion-ios-swap sort-icon"></i></span></th>
@elseif ($details['metric'] === 'new_partners')
<th data-sortable="true"><span class="sortable-label">Telefon <i class="ion ion-ios-swap sort-icon"></i></span></th>
<th data-sortable="true"><span class="sortable-label">Sponsor <i class="ion ion-ios-swap sort-icon"></i></span></th>
<th class="text-right" data-sortable="true" data-sort-type="number"><span class="sortable-label justify-content-end">Kundenpunkte <i class="ion ion-ios-swap sort-icon"></i></span></th>
<th class="text-right" data-sortable="true" data-sort-type="number" title="Eigen- und Kundenpunkte des Monats zusammen — inkl. Starterpaket"><span class="sortable-label justify-content-end">Punkte gesamt <i class="ion ion-ios-swap sort-icon"></i></span></th>
<th class="text-right" data-sortable="true" data-sort-type="number" title="Punkte aus den Bestellungen SEINER Kunden (Shop und Abo) — ohne seine eigenen Bestellungen."><span class="sortable-label justify-content-end">Punkte seiner Kunden <i class="ion ion-ios-swap sort-icon"></i></span></th>
<th class="text-right" data-sortable="true" data-sort-type="number" title="Punkte seiner Kunden PLUS seine eigenen Bestellungen — hier steckt vor allem das Starterpaket drin."><span class="sortable-label justify-content-end">Gesamt (inkl. Eigenbestellung) <i class="ion ion-ios-swap sort-icon"></i></span></th>
<th data-sortable="true" data-sort-type="date"><span class="sortable-label">Aktiv seit <i class="ion ion-ios-swap sort-icon"></i></span></th>
<th data-sortable="true" data-sort-type="date"><span class="sortable-label">Account gültig bis <i class="ion ion-ios-swap sort-icon"></i></span></th>
@else
@ -119,8 +120,8 @@
<td>{{ $row['email'] ?? '-' }}</td>
<td>{{ $row['career_level'] ?? '-' }}</td>
@if ($details['metric'] === 'team_customer_abos')
<td class="text-right">{{ number_format($row['abo_count'], 0, ',', '.') }}</td>
<td class="text-right">
<td class="text-right" data-sort-value="{{ $row['abo_count'] }}">{{ number_format($row['abo_count'], 0, ',', '.') }}</td>
<td class="text-right" data-sort-value="{{ $row['points'] }}">
<span class="font-weight-bold">{{ \App\Services\Util::formatNumber($row['points']) }}</span>
@if (! empty($row['abos']))
<a class="d-block small" data-toggle="collapse"
@ -145,37 +146,38 @@
</td>
@elseif ($details['metric'] === 'team_partner_abos')
<td>{{ $row['consultant_name'] ?? $row['name'] }}</td>
<td class="text-right">{{ \App\Services\Util::formatNumber($row['points']) }}</td>
<td>
<td class="text-right" data-sort-value="{{ $row['points'] }}">{{ \App\Services\Util::formatNumber($row['points']) }}</td>
<td data-sort-value="{{ $row['status_label'] ?? '' }}">
{!! $row['status_badge'] ?? '' !!}
@if (! empty($row['status_reason']))
<div class="small text-muted mt-1">{{ $row['status_reason'] }}</div>
@endif
</td>
<td>
<td data-sort-value="{{ $row['start_date_sort'] ?? '' }}">
{{ $row['start_date'] ?? '-' }}
@if (! empty($row['is_new_this_month']))
<span class="badge badge-success ml-1">Neu</span>
@endif
</td>
<td>{{ $row['next_date'] ?? '-' }}</td>
<td class="text-right">{{ number_format($row['deliveries'], 0, ',', '.') }}</td>
<td data-sort-value="{{ $row['next_date_sort'] ?? '' }}">{{ $row['next_date'] ?? '-' }}</td>
<td class="text-right" data-sort-value="{{ $row['deliveries'] }}">{{ number_format($row['deliveries'], 0, ',', '.') }}</td>
@elseif (in_array($details['metric'], ['own_points', 'external_points', 'customer_abo_points', 'customer_single_order_points', 'customer_other_points', 'total_points', 'shop_1000'], true))
<td class="text-right"><span class="badge" style="background-color:#d4edda;color:#155724;font-size:90%;">{{ \App\Services\Util::formatNumber($row['own_points']) }}</span></td>
<td class="text-right">{{ \App\Services\Util::formatNumber($row['customer_single_order_points']) }}</td>
<td class="text-right">{{ \App\Services\Util::formatNumber($row['customer_abo_points']) }}</td>
<td class="text-right">{{ \App\Services\Util::formatNumber($row['customer_other_points']) }}</td>
<td class="text-right font-weight-bold"><span class="badge" style="background-color:#d4edda;color:#155724;font-size:90%;">{{ \App\Services\Util::formatNumber($row['external_points']) }}</span></td>
<td class="text-right font-weight-bold">{{ \App\Services\Util::formatNumber($row['total_points']) }}</td>
<td class="text-right" data-sort-value="{{ $row['own_points'] }}"><span class="badge" style="background-color:#d4edda;color:#155724;font-size:90%;">{{ \App\Services\Util::formatNumber($row['own_points']) }}</span></td>
<td class="text-right" data-sort-value="{{ $row['customer_single_order_points'] }}">{{ \App\Services\Util::formatNumber($row['customer_single_order_points']) }}</td>
<td class="text-right" data-sort-value="{{ $row['customer_abo_points'] }}">{{ \App\Services\Util::formatNumber($row['customer_abo_points']) }}</td>
<td class="text-right" data-sort-value="{{ $row['customer_other_points'] }}">{{ \App\Services\Util::formatNumber($row['customer_other_points']) }}</td>
<td class="text-right font-weight-bold" data-sort-value="{{ $row['external_points'] }}"><span class="badge" style="background-color:#d4edda;color:#155724;font-size:90%;">{{ \App\Services\Util::formatNumber($row['external_points']) }}</span></td>
<td class="text-right font-weight-bold" data-sort-value="{{ $row['total_points'] }}">{{ \App\Services\Util::formatNumber($row['total_points']) }}</td>
@elseif ($details['metric'] === 'new_partners')
<td>{{ ($row['phone'] ?? '') !== '' ? $row['phone'] : '-' }}</td>
<td>{{ $row['sponsor'] ?? '-' }}</td>
<td class="text-right font-weight-bold">{{ \App\Services\Util::formatNumber($row['customer_points'] ?? 0) }}</td>
<td class="text-right font-weight-bold">{{ \App\Services\Util::formatNumber($row['total_points'] ?? 0) }}</td>
<td>{{ $row['active_date'] ?? '-' }}</td>
<td>{{ $row['payment_account'] ?? '-' }}</td>
<td class="text-right font-weight-bold" data-sort-value="{{ $row['customer_points'] ?? 0 }}">{{ \App\Services\Util::formatNumber($row['customer_points'] ?? 0) }}</td>
<td class="text-right font-weight-bold" data-sort-value="{{ $row['total_points'] ?? 0 }}">{{ \App\Services\Util::formatNumber($row['total_points'] ?? 0) }}</td>
<td data-sort-value="{{ $row['active_date_sort'] ?? '' }}">{{ $row['active_date'] ?? '-' }}</td>
<td data-sort-value="{{ $row['payment_account_sort'] ?? '' }}">{{ $row['payment_account'] ?? '-' }}</td>
@else
<td>{{ $row['active_date'] ?? '-' }}</td>
<td>
<td data-sort-value="{{ $row['active_date_sort'] ?? '' }}">{{ $row['active_date'] ?? '-' }}</td>
<td data-sort-value="{{ $row['payment_account_sort'] ?? '' }}">
{{ $row['payment_account'] ?? '-' }}
@if (isset($row['is_account_active']))
<span
@ -211,6 +213,7 @@
<td class="text-right" style="background-color:#d4edda;">{{ \App\Services\Util::formatNumber($details['summary']['external_points']) }}</td>
<td class="text-right">{{ \App\Services\Util::formatNumber($details['summary']['total_points']) }}</td>
@elseif ($details['metric'] === 'new_partners')
<td></td>
<td></td>
<td class="text-right">{{ \App\Services\Util::formatNumber($details['summary']['customer_points'] ?? 0) }}</td>
<td class="text-right">{{ \App\Services\Util::formatNumber($details['summary']['total_points'] ?? 0) }}</td>
@ -229,104 +232,9 @@
</div>
@if ($details['rows'] !== [])
<style>
#backoffice-statistics-detail-table {
min-width: 980px;
}
#backoffice-statistics-detail-table th,
#backoffice-statistics-detail-table td {
vertical-align: middle;
}
#backoffice-statistics-detail-table th[data-sortable="true"] {
white-space: nowrap;
}
#backoffice-statistics-detail-table .sortable-label {
align-items: center;
display: inline-flex;
gap: .25rem;
white-space: nowrap;
width: 100%;
}
#backoffice-statistics-detail-table .sort-icon {
color: #adb5bd;
flex: 0 0 auto;
font-size: .875rem;
line-height: 1;
}
#backoffice-statistics-detail-table th[data-sort-direction="asc"] .sort-icon,
#backoffice-statistics-detail-table th[data-sort-direction="desc"] .sort-icon {
color: #4e73df;
}
</style>
<script>
$(document).ready(function() {
$('#backoffice-statistics-detail-search').on('keyup search', function() {
var search = $(this).val().toLowerCase();
$('#backoffice-statistics-detail-table tbody tr').each(function() {
var rowText = $(this).text().toLowerCase();
$(this).toggle(rowText.indexOf(search) !== -1);
});
});
$('#backoffice-statistics-detail-table th[data-sortable="true"]').css('cursor', 'pointer').on('click',
function() {
var $header = $(this);
var columnIndex = $header.index();
var sortType = $header.data('sort-type') || 'text';
var direction = $header.data('sort-direction') === 'asc' ? 'desc' : 'asc';
var rows = $('#backoffice-statistics-detail-table tbody tr').get();
rows.sort(function(a, b) {
var aValue = getSortValue($(a).children('td').eq(columnIndex).text(), sortType);
var bValue = getSortValue($(b).children('td').eq(columnIndex).text(), sortType);
if (aValue < bValue) {
return direction === 'asc' ? -1 : 1;
}
if (aValue > bValue) {
return direction === 'asc' ? 1 : -1;
}
return 0;
});
$('#backoffice-statistics-detail-table th[data-sortable="true"]')
.removeData('sort-direction')
.removeAttr('data-sort-direction');
$header.data('sort-direction', direction).attr('data-sort-direction', direction);
$.each(rows, function(index, row) {
$('#backoffice-statistics-detail-table tbody').append(row);
});
});
function getSortValue(value, sortType) {
value = $.trim(value);
if (sortType === 'number') {
return parseFloat(value.replace(/\./g, '').replace(',', '.').replace(/[^\d.-]/g, '')) || 0;
}
if (sortType === 'date') {
var parts = value.match(/(\d{2})\.(\d{2})\.(\d{4})/);
if (!parts) {
return 0;
}
return new Date(parts[3], parts[2] - 1, parts[1]).getTime();
}
return value.toLowerCase();
}
});
</script>
@include('components._sortable_table', [
'tableId' => 'backoffice-statistics-detail-table',
'searchId' => 'backoffice-statistics-detail-search',
])
@endif
@endsection

View file

@ -9,8 +9,9 @@ use App\Services\AboHelper;
use App\User;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(Tests\TestCase::class, RefreshDatabase::class);
uses(TestCase::class, RefreshDatabase::class);
afterEach(fn () => Carbon::setTestNow());
@ -91,25 +92,29 @@ describe('Abo-Sperrfristen als Setting', function () {
->and(session('alert-error'))->not->toContain('mindestens 10 Tage');
});
it('erlaubt Pausieren im Change-Lock-Fenster, wenn außerhalb der Pause-Sperre (Intervall unverändert)', function () {
it('erlaubt Pausieren im Change-Lock-Fenster, wenn außerhalb der Pause-Sperre', function () {
Carbon::setTestNow('2026-03-19 00:00:00');
Setting::setContentBySlug('abo-min-duration', 0, 'int');
// 5 Tage entfernt: innerhalb Change-Lock (10), außerhalb Pause-Lock (3).
$abo = makeLockTestAbo(['abo_interval' => 20, 'next_date' => '2026-03-24', 'active' => true, 'status' => 2]);
$this->actingAs($abo->user);
$repository = new AboRepository;
$repository->setModel($abo);
// Intervall unverändert (20), kein abo_is_active => Pausieren.
$result = $repository->update([
'action' => 'abo_update_settings',
'id' => $abo->id,
'view' => 'me',
'abo_interval' => 20,
'abo_pause' => 'true',
]);
// Die Pause verschiebt nur den Liefertermin auf den Liefertag des Folgemonats;
// das Abo bleibt aktiv.
expect($result)->toBeTrue()
->and($abo->refresh()->active)->toBeFalse()
->and($abo->status)->toBe(6);
->and($abo->refresh()->active)->toBeTrue()
->and($abo->status)->toBe(2)
->and($abo->getRawOriginal('next_date'))->toBe('2026-04-20');
});
});

View file

@ -0,0 +1,246 @@
<?php
use App\Models\Country;
use App\Models\Setting;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use App\Models\UserAboStatusHistory;
use App\Repositories\AboRepository;
use App\Services\AboHelper;
use App\User;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(fn () => Carbon::setTestNow('2026-03-09 10:00:00'));
afterEach(fn () => Carbon::setTestNow());
/**
* A01 — Der Aktiv-Zustand darf sich im Selbstbedienungspfad nur über die dafür
* zugelassenen Aktionen ändern. Das Formular sendet ihn als verstecktes Feld mit;
* ein manipulierter Request könnte die Mindestlaufzeit sonst durch dauerhafte
* Deaktivierung umgehen, statt den Ein-Zyklus-Pausenpfad mit seinen Sperren zu
* benutzen.
*/
describe('A01: Aktiv-Schalter im Selbstbedienungspfad', function () {
it('deaktiviert das Abo nicht, wenn das Aktiv-Feld auf 0 gesetzt wird', function (string $view) {
$abo = makeStatusGuardAbo(['is_for' => $view === 'ot' ? 'ot' : 'me']);
$this->actingAs($abo->user);
$result = statusGuardUpdate($abo, [
'view' => $view,
'abo_is_active' => '0',
]);
expect($result)->toBeTrue()
->and($abo->refresh()->active)->toBeTrue()
->and($abo->status)->toBe(2)
->and($abo->status_histories()->count())->toBe(0);
})->with(['me', 'ot', 'portal']);
it('deaktiviert das Abo auch dann nicht, wenn das Aktiv-Feld ganz fehlt', function () {
$abo = makeStatusGuardAbo();
$this->actingAs($abo->user);
statusGuardUpdate($abo, ['view' => 'me']);
expect($abo->refresh()->active)->toBeTrue()
->and($abo->status)->toBe(2);
});
it('reaktiviert ein deaktiviertes Abo nicht über das Aktiv-Feld', function () {
$abo = makeStatusGuardAbo(['active' => false, 'status' => 6]);
$this->actingAs($abo->user);
statusGuardUpdate($abo, [
'view' => 'me',
'abo_is_active' => 'true',
]);
expect($abo->refresh()->active)->toBeFalse()
->and($abo->status)->toBe(6);
});
it('lässt wiederholte Umschaltversuche wirkungslos', function () {
$abo = makeStatusGuardAbo();
$this->actingAs($abo->user);
foreach (['0', 'true', '0'] as $value) {
statusGuardUpdate($abo, ['view' => 'me', 'abo_is_active' => $value]);
}
expect($abo->refresh()->active)->toBeTrue()
->and($abo->status_histories()->count())->toBe(0);
});
it('setzt die Mindestlaufzeit auch bei manipuliertem Aktiv-Feld durch', function () {
Setting::setContentBySlug('abo-min-duration', 3, 'int');
$abo = makeStatusGuardAbo();
$this->actingAs($abo->user);
// Ohne bezahlte Lieferungen ist weder Kündigung noch Pause erlaubt …
expect(AboHelper::canCancelAbo($abo, 'me'))->toBeFalse();
// … und der Umweg über das Aktiv-Feld führt ebenfalls nicht zum Ziel.
statusGuardUpdate($abo, ['view' => 'me', 'abo_is_active' => '0']);
expect($abo->refresh()->active)->toBeTrue();
});
it('lässt den Admin weiterhin de- und reaktivieren', function () {
$abo = makeStatusGuardAbo();
$admin = User::forceCreate([
'email' => 'status-guard-admin-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'admin' => 4,
]);
$this->actingAs($admin);
statusGuardUpdate($abo, ['view' => 'admin', 'abo_next_month' => '2026-03']);
expect($abo->refresh()->active)->toBeFalse()
->and($abo->status)->toBe(6);
statusGuardUpdate($abo, ['view' => 'admin', 'abo_next_month' => '2026-03', 'abo_is_active' => 'true']);
expect($abo->refresh()->active)->toBeTrue()
->and($abo->status)->toBe(2);
});
});
/**
* A16 — Jeder Wechsel des Aktiv-Zustands muss protokolliert werden, egal welcher
* Schreibpfad ihn auslöst. Die historische Bestandsrückrechnung verwirft ein Abo,
* solange eine protokollierte Deaktivierung nicht durch eine spätere Reaktivierung
* aufgehoben ist.
*/
describe('A16: Statushistorie über alle Schreibpfade', function () {
it('protokolliert Deaktivierung und Reaktivierung aus dem Admin-Formular mit Kanal und Akteur', function () {
$abo = makeStatusGuardAbo();
$admin = User::forceCreate([
'email' => 'status-guard-admin-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'admin' => 4,
]);
$this->actingAs($admin);
statusGuardUpdate($abo, ['view' => 'admin', 'abo_next_month' => '2026-03']);
statusGuardUpdate($abo, ['view' => 'admin', 'abo_next_month' => '2026-03', 'abo_is_active' => 'true']);
$entries = $abo->status_histories()->orderBy('id')->get();
expect($entries->pluck('action')->all())->toBe([
UserAboStatusHistory::ACTION_DEACTIVATED,
UserAboStatusHistory::ACTION_REACTIVATED,
])
->and($entries->pluck('view')->unique()->all())->toBe(['admin'])
->and($entries->pluck('actor_user_id')->unique()->all())->toBe([$admin->id]);
});
it('protokolliert die Kündigung aus dem Selbstbedienungspfad', function () {
Setting::setContentBySlug('abo-min-duration', 0, 'int');
$abo = makeStatusGuardAbo(['next_date' => '2026-04-20']);
$this->actingAs($abo->user);
statusGuardUpdate($abo, ['view' => 'me', 'abo_cancel' => 'true']);
$entry = $abo->status_histories()->sole();
expect($abo->refresh()->active)->toBeFalse()
->and($abo->status)->toBe(4)
->and($entry->action)->toBe(UserAboStatusHistory::ACTION_CANCELLED)
->and($entry->view)->toBe('me');
});
it('protokolliert eine Reaktivierung aus einem Systemschreibpfad ohne Formularkanal', function () {
// Stellvertretend für Recovery-Zahlung, Retry und externe Erledigung: alle
// schreiben status=2/active=true direkt auf das Modell.
$abo = makeStatusGuardAbo(['active' => false, 'status' => 6]);
$abo->update(['status' => 2, 'active' => true]);
$entry = $abo->status_histories()->sole();
expect($entry->action)->toBe(UserAboStatusHistory::ACTION_REACTIVATED)
->and($entry->view)->toBeNull()
->and($entry->actor_user_id)->toBeNull();
});
it('schreibt keinen Eintrag, wenn der Aktiv-Zustand unverändert bleibt', function () {
$abo = makeStatusGuardAbo();
$abo->update(['status' => 2, 'active' => true, 'last_date' => now()]);
expect($abo->status_histories()->count())->toBe(0);
});
it('protokolliert nicht beim Löschen — dafür wertet die Rückrechnung deleted_at aus', function () {
$abo = makeStatusGuardAbo();
$abo->delete();
expect($abo->status_histories()->count())->toBe(0)
->and(UserAbo::withTrashed()->find($abo->id)->active)->toBeFalse();
});
});
/**
* @param array<string, mixed> $data
*/
function statusGuardUpdate(UserAbo $abo, array $data): mixed
{
$repository = new AboRepository;
$repository->setModel($abo);
return $repository->update(array_merge([
'action' => 'abo_update_settings',
'id' => $abo->id,
'abo_interval' => $abo->abo_interval,
], $data));
}
/**
* @param array<string, mixed> $overrides
*/
function makeStatusGuardAbo(array $overrides = []): UserAbo
{
$country = Country::create([
'code' => 'DE', 'phone' => '49', 'en' => 'Germany', 'de' => 'Deutschland',
'es' => 'Alemania', 'fr' => 'Allemagne', 'it' => 'Germania', 'ru' => 'Deutschland',
]);
$user = User::forceCreate([
'email' => 'status-guard-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'admin' => 0,
]);
$shoppingUser = ShoppingUser::create([
'auth_user_id' => $user->id,
'member_id' => $user->id,
'billing_country_id' => $country->id,
'shipping_country_id' => $country->id,
'billing_email' => $user->email,
'is_for' => 'me',
'is_from' => 'user_order',
]);
return UserAbo::create(array_merge([
'user_id' => $user->id,
'member_id' => $user->id,
'shopping_user_id' => $shoppingUser->id,
'is_for' => 'me',
'email' => $user->email,
'payone_userid' => random_int(100000, 999999),
'clearingtype' => 'cc',
'active' => true,
'status' => 2,
'abo_interval' => 20,
'start_date' => '2026-01-20',
'last_date' => '2026-02-20',
'next_date' => '2026-03-20',
], $overrides));
}

View file

@ -3,6 +3,7 @@
use App\Models\Product;
use App\Models\UserAbo;
use App\Models\UserAboItem;
use App\Models\UserAccount;
use App\Models\UserSalesVolume;
use App\Services\Abo\TeamAboOverviewService;
use App\Services\Backoffice\BackofficeDashboardService;
@ -270,3 +271,67 @@ it('filtert Abo-Drilldowns mit only_new auf die im Monat neuen Abos', function (
expect($onlyNewCustomers['rows'])->toHaveCount(1)
->and($onlyNewCustomers['rows'][0]['abo_count'])->toBe(1);
});
it('liefert im Neupartner-Drilldown die Telefonnummer mit Mobil-Fallback', function (): void {
// Kundenwunsch 2026-09-07: zum Nachfassen gehoert die Telefonnummer in die Liste.
$root = boMakeUser();
$mitFestnetz = boMakeUser($root->id, '2026-06-05');
$mitFestnetz->forceFill(['account_id' => UserAccount::forceCreate([
'first_name' => 'Fritz', 'last_name' => 'Festnetz', 'phone' => '0301234', 'mobil' => '01700000',
])->id])->save();
$nurMobil = boMakeUser($root->id, '2026-06-06');
$nurMobil->forceFill(['account_id' => UserAccount::forceCreate([
'first_name' => 'Mona', 'last_name' => 'Mobil', 'phone' => '', 'mobil' => '01711111',
])->id])->save();
$ohne = boMakeUser($root->id, '2026-06-07');
$rows = collect(app(BackofficeDrilldownService::class)->details($root, 0, 'new_partners', 6, 2026)['rows'])
->keyBy('user_id');
expect($rows->get($mitFestnetz->id)['phone'])->toBe('0301234')
->and($rows->get($nurMobil->id)['phone'])->toBe('01711111')
->and($rows->get($ohne->id)['phone'])->toBe('');
});
it('liefert Sortierwerte als ISO-Datum, unabhaengig von der Anzeigesprache (A14)', function (string $locale): void {
app()->setLocale($locale);
$root = boMakeUser();
$anna = boMakeUser($root->id, '2026-03-15');
boMakeAbo($anna->id, 'me', '2026-06-10', 20);
$service = app(BackofficeDrilldownService::class);
$abo = $service->details($root, 0, 'team_partner_abos', 6, 2026)['rows'][0];
expect($abo['start_date_sort'])->toBe('2026-06-10')
->and($abo['next_date_sort'])->toBe('2026-12-10');
$consultant = collect($service->details($root, 0, 'consultants', 6, 2026)['rows'])
->firstWhere('user_id', $anna->id);
expect($consultant['active_date_sort'])->toBe('2026-03-15');
})->with(['de', 'en']);
it('haengt die Sortierwerte an die Zellen der Drilldown-Tabelle (A14)', function (): void {
URL::resolveMissingNamedRoutesUsing(fn (string $name, $parameters = []) => '/'.$name);
$root = boMakeUser();
$anna = boMakeUser($root->id, '2026-03-15');
boMakeAbo($anna->id, 'me', '2026-06-10', 20);
$this->actingAs($root);
$details = app(BackofficeDrilldownService::class)->details($root, 0, 'team_partner_abos', 6, 2026);
$html = view('user.backoffice.statistics.details', [
'details' => $details,
'selectedMonth' => 6,
'selectedYear' => 2026,
])->render();
expect($html)
->toContain('data-sort-value="2026-06-10"')
->toContain('data-sort-value="2026-12-10"')
->toContain('data-sort-value="20"');
});

View file

@ -0,0 +1,137 @@
<?php
use App\Models\UserAbo;
use App\User;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\URL;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
Carbon::setTestNow(Carbon::parse('2026-09-07'));
URL::resolveMissingNamedRoutesUsing(fn (string $name, $parameters = []) => '/'.$name);
});
afterEach(function () {
Carbon::setTestNow();
});
function calMakeAbo(int $memberId, string $startDate, string $nextDate, int $interval = 5): UserAbo
{
return UserAbo::create([
'user_id' => $memberId,
'member_id' => $memberId,
'shopping_user_id' => 0,
'email' => 'cal-'.uniqid('', true).'@example.com',
'is_for' => 'ot',
'payone_userid' => 0,
'clearingtype' => 'cc',
'amount' => 5000,
'active' => true,
'status' => 2,
'abo_interval' => $interval,
'start_date' => $startDate,
'next_date' => $nextDate,
]);
}
it('rendert die Kundenabo-Liste mit allen gewuenschten Spalten', function (): void {
// Kundenwunsch 2026-09-07: Name, Liefertag, Abo-Punkte, naechste Ausfuehrung,
// Startdatum, Ausfuehrungen — sortierbar statt als Karten.
$user = User::forceCreate([
'email' => 'cal-owner@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'm_level' => 1,
'payment_account' => now()->addYear(),
]);
$abo = calMakeAbo($user->id, '2026-04-20', '2026-09-20', 20);
$html = view('user.abo._customer_abo_list', [
'listAbos' => collect([$abo]),
'customerAboForecast' => ['base' => 0.0, 'days' => [
['day' => 5, 'date' => '05.09.2026', 'upcoming' => 0.0, 'total' => 0.0, 'is_past' => true],
['day' => 10, 'date' => '10.09.2026', 'upcoming' => 36.0, 'total' => 36.0, 'is_past' => false],
['day' => 20, 'date' => '20.09.2026', 'upcoming' => 81.0, 'total' => 81.0, 'is_past' => false],
['day' => 25, 'date' => '25.09.2026', 'upcoming' => 81.0, 'total' => 81.0, 'is_past' => false],
]],
])->render();
expect($html)
->toContain(e(__('tables.abo_delivery_day')))
->toContain(e(__('tables.next_date')))
->toContain(e(__('tables.start_date')))
->toContain(e(__('tables.abo_delivery')))
// Sortier-Partial ist eingebunden und haengt an der richtigen Tabelle
->toContain('customer-abo-table')
->toContain('data-sortable="true"')
// Liefertag-Prognose aus den eigenen Kundenabos
->toContain('81')
->toContain(e(__('abo.customer_abo_forecast_title')));
});
it('rendert die Liste auch ohne Liefertag-Prognose', function (): void {
// Fuer vergangene Monate uebergibt der Controller null — die Tabelle bleibt.
$user = User::forceCreate([
'email' => 'cal-owner2@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'm_level' => 1,
'payment_account' => now()->addYear(),
]);
$html = view('user.abo._customer_abo_list', [
'listAbos' => collect([calMakeAbo($user->id, '2026-04-20', '2026-09-20')]),
'customerAboForecast' => null,
])->render();
expect($html)->toContain('customer-abo-table')
->not->toContain(e(__('abo.customer_abo_forecast_title')));
});
it('kompiliert die Abo-Seite vollstaendig, ohne Blade-Direktiven zu verschlucken', function (string $view): void {
// Regression 2026-09-07: Wird die Ausdrucksform @php(...) mit einem
// @php ... @endphp-Block in derselben Datei gemischt, matcht Blades
// storePhpBlocks vom ersten @php bis zum ersten @endphp und schluckt
// alles dazwischen — dann fehlen Zuweisungen wie $cardAbos zur Laufzeit.
$source = file_get_contents(resource_path('views/'.str_replace('.', '/', $view).'.blade.php'));
$compiled = app('blade.compiler')->compileString($source);
expect($compiled)->not->toMatch('/@(if|unless|foreach|forelse|php|endif|endforeach|endphp)\b/');
})->with([
'user.abo.index',
'user.abo._customer_abo_list',
'dashboard._statistics',
'user.team.firstlines',
'user.team.points',
'user.team.abos',
'components._sortable_table',
]);
it('gibt unformatierte Sortierwerte aus, unabhaengig von der Sprache (A14)', function (string $locale): void {
// Der Zellentext ist lokalisiert (en: `Y-m-d` und `1,000.00`), der
// Sortierschluessel nicht — sonst sortiert die Tabelle in en falsch.
app()->setLocale($locale);
$user = User::forceCreate([
'email' => 'cal-sort-'.$locale.'@example.com',
'password' => bcrypt('secret'),
'lang' => $locale,
'm_level' => 1,
'payment_account' => now()->addYear(),
]);
$html = view('user.abo._customer_abo_list', [
'listAbos' => collect([calMakeAbo($user->id, '2026-04-05', '2026-09-20', 20)]),
'customerAboForecast' => null,
])->render();
expect($html)
->toContain('data-sort-value="2026-09-20"') // naechste Ausfuehrung
->toContain('data-sort-value="2026-04-05"') // Startdatum
->toContain('data-sort-value="20"'); // Liefertag
})->with(['de', 'en']);

View file

@ -1,6 +1,10 @@
<?php
use App\Http\Controllers\HomeController;
use App\Models\UserAbo;
use App\Models\UserLevel;
use App\Services\Abo\PaylineForecastService;
use App\Services\BusinessPlan\LiveCommissionService;
use App\User;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -31,9 +35,9 @@ function hmsMakeUser(?int $sponsorId = null, ?string $paymentAccount = 'future')
]);
}
function hmsMonthlyStats(User $user): array
function hmsMonthlyStats(User $user, int $month = 8, int $year = 2026): array
{
Request::merge(['stats_month' => 8, 'stats_year' => 2026]);
Request::merge(['stats_month' => $month, 'stats_year' => $year]);
$method = (new ReflectionClass(HomeController::class))->getMethod('buildMonthlyStats');
$method->setAccessible(true);
@ -75,3 +79,119 @@ it('zaehlt Neupartner weiterhin ueber alle Firstlines, nicht nur ueber die Kache
expect($stats['direct_new_partners'])->toBe(1)
->and($stats['firstline_count'])->toBe(2);
});
/**
* Legt ein Karriere-Level an und haengt den User daran. Ohne Monatsqualifikation
* ist genau das die Ebenenzahl, mit der die beiden Payline-Kacheln rechnen.
*/
function hmsMakeLevel(int $pos, int $paylines, string $name = 'Testlevel'): UserLevel
{
return UserLevel::forceCreate([
'name' => $name.' '.$pos,
'qual_kp' => 150,
'qual_pp' => 0,
'paylines' => $paylines,
'pos' => $pos,
'active' => true,
]);
}
it('zeigt die Payline-Kacheln am Monatsanfang nach dem Karriere-Level, statt auf 0 zu fallen', function (): void {
// Ohne Monatsquali liefert der Live-Lauf paylines = 0. Die beiden
// Struktur-Kacheln muessen dann auf die Ebenen des Karriere-Levels
// zurueckfallen — die Teamstruktur aendert sich zum Monatswechsel nicht.
$level = hmsMakeLevel(pos: 3, paylines: 2);
$root = hmsMakeUser();
$root->forceFill(['m_level' => $level->id])->save();
$line1 = hmsMakeUser($root->id);
$line2 = hmsMakeUser($line1->id);
// Ebene 3 liegt ausserhalb der zwei bezahlten Ebenen des Karriere-Levels.
hmsMakeUser($line2->id);
$stats = hmsMonthlyStats($root->fresh());
expect($stats['payline_is_career_fallback'])->toBeTrue()
->and($stats['payline_member_count'])->toBe(2)
->and($stats['team_partner_count'])->toBe(3);
});
it('haelt eine leere Payline nicht im 6-h-Cache fest', function (): void {
// Ohne Karriere-Level bleibt die Tiefe 0. Das darf NICHT gecacht werden:
// der Zustand loest sich im Monatsverlauf von selbst auf.
$root = hmsMakeUser();
hmsMakeUser($root->id);
$service = app(PaylineForecastService::class);
$context = $service->paylineContext($root, 8, 2026);
expect($context['paylines'])->toBe(0)
->and($context['member_ids'])->toBe([])
->and(Cache::has("payline_context_{$root->id}_8_2026"))->toBeFalse();
});
it('cacht den Payline-Kontext, sobald eine Ebenenzahl feststeht', function (): void {
$level = hmsMakeLevel(pos: 3, paylines: 2);
$root = hmsMakeUser();
$root->forceFill(['m_level' => $level->id])->save();
hmsMakeUser($root->id);
$service = app(PaylineForecastService::class);
$context = $service->paylineContext($root->fresh(), 8, 2026);
expect($context['paylines'])->toBe(2)
->and(Cache::has("payline_context_{$root->id}_8_2026"))->toBeTrue();
});
it('blendet den Level-Block ohne Monatsqualifikation nicht aus, sondern zeigt die fehlenden Punkte', function (): void {
// setFirstQualLevel() liefert das Level auf pos 1 als next_level mit —
// daraus muss "dir fehlen noch X Punkte persoenliches Volumen" entstehen.
hmsMakeLevel(pos: 1, paylines: 3, name: 'Junior Berater');
$root = hmsMakeUser();
$root->forceFill(['m_level' => UserLevel::where('pos', 1)->value('id')])->save();
$level = app(LiveCommissionService::class)
->levelProgress($root->fresh(), 8, 2026);
expect($level)->not->toBeNull()
->and($level['is_qualified'])->toBeFalse()
->and($level['name'])->toBe('')
->and($level['next_name'])->toBe('Junior Berater 1')
->and($level['missing_personal'])->toBe(150.0);
});
it('rechnet Bestand und "+N neu" der Payline-Kachel nach derselben Regel', function (): void {
// Regression: der Bestand kam aus der Monatsend-Rueckrechnung, das Badge aus
// activeAbo(). In derselben Kachel standen damit zwei Zaehlweisen.
$level = hmsMakeLevel(pos: 3, paylines: 2);
$root = hmsMakeUser();
$root->forceFill(['m_level' => $level->id])->save();
$anna = hmsMakeUser($root->id);
$abo = UserAbo::create([
'user_id' => $anna->id,
'member_id' => $anna->id,
'shopping_user_id' => 0,
'email' => 'hms-abo@example.com',
'is_for' => 'ot',
'payone_userid' => 0,
'clearingtype' => 'cc',
'amount' => 5000,
'active' => false,
'status' => 2,
'abo_interval' => 5,
'start_date' => '2026-07-10',
'next_date' => '2026-08-25',
]);
// Erst im August beendet -> zaehlt im Juli sowohl im Bestand als auch als "neu".
$abo->update(['cancel_date' => '2026-08-05']);
$stats = hmsMonthlyStats($root->fresh(), month: 7, year: 2026);
expect($stats['payline_abo_count'])->toBe(1)
->and($stats['payline_abos_new'])->toBe(1);
});

View file

@ -181,3 +181,81 @@ it('reicht Level- und Quali-Daten im Snapshot durch (N1/N4)', function (): void
->and($snapshot['personal_points'])->toBe(300.0)
->and($snapshot['paylines'])->toBe(3);
});
it('reicht das Fehlerkennzeichen der Provisionsberechnung durch', function (): void {
// A19: Eine fehlgeschlagene Live-Berechnung liefert Ersatzwerte, aus denen sich
// 0,00 EUR ergibt. Ohne das Kennzeichen ist das in der Oberflaeche nicht von
// einem echten Nullmonat zu unterscheiden.
$user = lcsMakeUser();
$service = Mockery::mock(LiveCommissionService::class)->makePartial();
$service->shouldReceive('snapshot')->once()->andReturn([
'payline_points' => 0.0,
'paylines' => 0,
'pp' => 0.0,
'shop' => 0.0,
'growth' => 0.0,
'is_live' => true,
'failed' => true,
'qual_level' => null,
'next_level' => null,
'payline_points_qual_kp' => 0.0,
'personal_points' => 0.0,
]);
$commission = $service->commission($user, 7, 2026);
expect($commission['failed'])->toBeTrue()
->and($commission['total'])->toBe(0.0);
});
it('meldet eine echte Nullprovision ohne Fehlerkennzeichen', function (): void {
$user = lcsMakeUser();
UserBusiness::forceCreate([
'user_id' => $user->id,
'month' => 6,
'year' => 2026,
'version' => 2,
'commission_pp_total' => 0,
'commission_shop_sales' => 0,
'commission_growth_total' => 0,
]);
$commission = app(LiveCommissionService::class)->commission($user, 6, 2026);
expect($commission['total'])->toBe(0.0)
->and($commission['failed'])->toBeFalse();
});
it('zeigt eine nicht verfuegbare Provisionsberechnung statt 0,00 Euro', function (): void {
URL::resolveMissingNamedRoutesUsing(fn (string $name, $parameters = []) => '/'.$name);
$html = view('dashboard._statistics', [
'monthlyStats' => [
'month' => 7,
'year' => 2026,
'customer_points' => 0.0,
'team_payline_points' => 0.0,
'payline_is_live' => true,
'payline_forecast' => null,
'commission' => ['total' => 0.0, 'pp' => 0.0, 'shop' => 0.0, 'growth' => 0.0, 'is_live' => true, 'failed' => true],
'direct_new_partners' => 0,
'team_total_abos' => 0,
'team_partner_abos' => 0,
'team_partner_abos_new' => 0,
'team_customer_abos' => 0,
'team_customer_abos_new' => 0,
'shop_1000' => 0,
'new_partners' => 0,
'own_abos' => 0,
'own_abos_new' => 0,
'own_customer_abos' => 0,
'own_customer_abos_new' => 0,
],
])->render();
expect($html)
->toContain(__('home.commission_unavailable'))
->not->toContain('0,00 €');
});

View file

@ -31,8 +31,11 @@ afterEach(function () {
Carbon::setTestNow();
});
function pflMakeUser(?int $sponsorId = null): User
function pflMakeUser(?int $sponsorId = null, ?string $activeDate = '2026-01-10'): User
{
// active_date ist gesetzt wie in der Produktion: der Provisionsbaum nimmt nur
// Teampartner auf, die bis zum Monatsende aktiv geworden sind
// (BusinessUserItemOptimized::readParentsBusinessUsers()).
return User::forceCreate([
'email' => 'pfl-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
@ -40,6 +43,7 @@ function pflMakeUser(?int $sponsorId = null): User
'm_level' => 1,
'm_sponsor' => $sponsorId,
'payment_account' => now()->addYear(),
'active_date' => $activeDate,
]);
}
@ -85,11 +89,14 @@ function pflMakeAbo(int $userId, string $isFor, string $nextDate, int $points, i
return $abo->fresh();
}
function pflSeedContext(User $user, array $memberIds, int $paylines = 2): void
function pflSeedContext(User $user, array $memberIds, int $paylines = 2, ?int $month = null, ?int $year = null): void
{
$month ??= (int) now()->month;
$year ??= (int) now()->year;
Cache::put(
"payline_context_{$user->id}_".now()->month.'_'.now()->year,
['paylines' => $paylines, 'member_ids' => $memberIds],
"payline_context_{$user->id}_{$month}_{$year}",
['paylines' => $paylines, 'member_ids' => $memberIds, 'is_career_fallback' => false],
now()->addHour()
);
}
@ -135,6 +142,67 @@ it('markiert vergangene Termine und zaehlt nur noch offene Ausfuehrungen', funct
->and($forecast['days'][3])->toMatchArray(['day' => 25, 'upcoming' => 20.0, 'total' => 120.0, 'is_past' => false]);
});
it('zaehlt eine heute faellige, noch nicht ausgefuehrte Lieferung mit (A13)', function (): void {
// 10.07., 00:01 Uhr: der Ausfuehrungsjob laeuft erst um 04:00. Bis dahin ist
// das Abo weder im Ist gebucht noch — bisher — in der Prognose enthalten.
Carbon::setTestNow(Carbon::parse('2026-07-10 00:01:00'));
$root = pflMakeUser();
$anna = pflMakeUser($root->id);
pflSeedContext($root, [$anna->id]);
pflMakeAbo($anna->id, 'ot', '2026-07-10', 50);
$forecast = app(PaylineForecastService::class)->forecastDays($root, 0.0);
expect($forecast['days'][1])->toMatchArray(['day' => 10, 'upcoming' => 50.0])
->and($forecast['days'][3])->toMatchArray(['day' => 25, 'upcoming' => 50.0]);
});
it('zaehlt eine heute bereits ausgefuehrte Lieferung nicht doppelt (A13)', function (): void {
Carbon::setTestNow(Carbon::parse('2026-07-10 06:00:00'));
$root = pflMakeUser();
$anna = pflMakeUser($root->id);
pflSeedContext($root, [$anna->id]);
// Der Job hat die Ausfuehrung angelegt; die Punkte stecken damit im Ist.
$abo = pflMakeAbo($anna->id, 'ot', '2026-07-10', 50);
$abo->user_abo_orders()->create(['shopping_order_id' => 0, 'status' => 1, 'paid' => true]);
$forecast = app(PaylineForecastService::class)->forecastDays($root, 0.0);
expect($forecast['days'][1])->toMatchArray(['day' => 10, 'upcoming' => 0.0])
->and($forecast['days'][3])->toMatchArray(['day' => 25, 'upcoming' => 0.0]);
});
it('zaehlt eine gestern faellige Lieferung nicht mehr mit (A13)', function (): void {
// Nur der heutige Liefertag ist offen; ein liegengebliebener Vortag gehoert
// in die Nachbearbeitung, nicht in die Prognose.
Carbon::setTestNow(Carbon::parse('2026-07-11 09:00:00'));
$root = pflMakeUser();
$anna = pflMakeUser($root->id);
pflSeedContext($root, [$anna->id]);
pflMakeAbo($anna->id, 'ot', '2026-07-10', 50);
$forecast = app(PaylineForecastService::class)->forecastDays($root, 0.0);
expect($forecast['days'][3])->toMatchArray(['day' => 25, 'upcoming' => 0.0]);
});
it('zaehlt den heutigen Liefertag auch in der persoenlichen Vorschau (A13)', function (): void {
Carbon::setTestNow(Carbon::parse('2026-07-10 00:01:00'));
$berater = pflMakeUser();
pflMakeAbo($berater->id, 'ot', '2026-07-10', 50);
$forecast = app(PaylineForecastService::class)->personalCustomerAboForecast($berater);
expect($forecast['days'][1])->toMatchArray(['day' => 10, 'upcoming' => 50.0]);
});
it('liefert Soll und Ist des Payline-Abo-Umsatzes', function (): void {
$root = pflMakeUser();
$anna = pflMakeUser($root->id);
@ -327,3 +395,171 @@ it('prognostiziert Level und Provision zum letzten Liefertag', function (): void
->and($forecast['estimated_level_name'])->toBe('Gold')
->and($forecast['estimated_commission'])->toBe(63.0);
});
it('haelt das "+N neu"-Badge der Payline-Kachel fuer abgeschlossene Monate stabil', function (): void {
// Regression: das Badge zaehlte ueber activeAbo() (heutiger Stand), der
// Bestand daneben ueber die Monatsend-Rueckrechnung. Ein im Juli gestartetes
// und im August beendetes Abo fiel damit aus der Juli-Zahl heraus.
Carbon::setTestNow(Carbon::parse('2026-08-15'));
$root = pflMakeUser();
$anna = pflMakeUser($root->id);
pflSeedContext($root, [$anna->id], month: 7, year: 2026);
pflMakeAbo($anna->id, 'ot', '2026-08-25', 30)
->update(['start_date' => '2026-07-05', 'active' => false, 'cancel_date' => '2026-08-05']);
pflMakeAbo($anna->id, 'me', '2026-08-10', 20)->update(['start_date' => '2026-07-03']);
// Im August gestartet: gehoert nicht in die Juli-Zahl.
pflMakeAbo($anna->id, 'ot', '2026-08-20', 15)->update(['start_date' => '2026-08-02']);
expect(app(PaylineForecastService::class)->newPaylineAboCount($root, 7, 2026))->toBe(2);
});
it('rechnet die Prognose ueber denselben Baum wie der Provisionslauf', function (): void {
// lineBuckets() nimmt jeden Downline-User, der TreeCalcBot filtert und kappt
// den Unterbaum darunter. Ohne denselben Filter zaehlte die Prognose Punkte
// mit, die in den payline_points der Level-Kachel gar nicht stecken —
// zwei verschiedene "fehlen noch X Punkte" auf einer Seite.
foreach (['sales_volume_points_KP_sum' => 'float', 'payline_points_qual_kp' => 'float', 'qual_user_level_next' => 'text'] as $column => $type) {
if (! Schema::connection('sqlite')->hasColumn('user_businesses', $column)) {
Schema::connection('sqlite')->table('user_businesses', function ($table) use ($column, $type) {
$type === 'float' ? $table->float($column)->nullable() : $table->text($column)->nullable();
});
}
}
$root = pflMakeUser();
// Ebene 1 ohne Abrechnungskonto: steht nicht im Provisionsbaum ...
$inaktiv = pflMakeUser($root->id);
$inaktiv->forceFill(['payment_account' => null])->save();
// ... und damit auch nicht sein Teampartner auf Ebene 2.
$unterInaktiv = pflMakeUser($inaktiv->id);
// Ebene 1, erst nach dem Monatsende aktiv geworden: ebenfalls draussen.
pflMakeUser($root->id, '2026-08-15');
$anna = pflMakeUser($root->id); // zaehlt
$levelBase = [
'qual_kp' => 0, 'growth_bonus' => 0,
'pr_line_1' => 10, 'pr_line_2' => 0, 'pr_line_3' => 0, 'pr_line_4' => 0,
'pr_line_5' => 0, 'pr_line_6' => 0, 'pr_line_7' => 0, 'pr_line_8' => 0,
];
UserBusiness::forceCreate([
'user_id' => $root->id,
'month' => 7, 'year' => 2026, 'version' => 1,
'payline_points' => 100.0,
'payline_points_qual_kp' => 100.0,
'sales_volume_points_KP_sum' => 0.0,
'commission_pp_total' => 0, 'commission_shop_sales' => 0, 'commission_growth_total' => 0,
'qual_user_level' => array_merge($levelBase, ['name' => 'Gold', 'paylines' => 2, 'qual_pp' => 0]),
'qual_user_level_next' => array_merge($levelBase, ['name' => 'Diamant', 'paylines' => 2, 'qual_pp' => 1000, 'qual_kp' => 0]),
]);
foreach ([$inaktiv->id => 500, $unterInaktiv->id => 300, $anna->id => 100] as $userId => $points) {
UserSalesVolume::forceCreate([
'user_id' => $userId, 'month' => 7, 'year' => 2026,
'points' => $points, 'status' => 1, 'month_TP_points' => $points, 'month_shop_points' => 0,
]);
}
$forecast = app(PaylineForecastService::class)->levelAndCommissionForecast($root, 7, 2026);
// Nur Annas 100 Punkte zaehlen -> zu 1000 fehlen 900 (nicht 1000 - 900 = 100).
expect($forecast['missing_pp'])->toBe(900.0)
->and($forecast['estimated_commission'])->toBe(10.0);
});
it('prognostiziert die Liefertage ausschliesslich aus den eigenen Kundenabos', function (): void {
// Kundenwunsch 2026-09-07: "wieviel Volumenpunkte habe ich am 5./10./20./25.
// Nur mit meinen Kundenabos" — ohne eigenes Abo, ohne Team.
$root = pflMakeUser();
$anna = pflMakeUser($root->id);
pflMakeAbo($root->id, 'ot', '2026-07-10', 30); // eigenes Kundenabo
pflMakeAbo($root->id, 'ot', '2026-07-25', 45); // eigenes Kundenabo
pflMakeAbo($root->id, 'me', '2026-07-10', 99); // eigenes Abo zaehlt NICHT
pflMakeAbo($anna->id, 'ot', '2026-07-05', 77); // Team zaehlt NICHT
pflMakeAbo($root->id, 'ot', '2026-08-05', 50); // naechster Monat zaehlt nicht
$forecast = app(PaylineForecastService::class)->personalCustomerAboForecast($root);
expect($forecast['base'])->toBe(0.0)
->and($forecast['days'])->toHaveCount(4)
->and($forecast['days'][0])->toMatchArray(['day' => 5, 'upcoming' => 0.0, 'total' => 0.0])
->and($forecast['days'][1])->toMatchArray(['day' => 10, 'upcoming' => 30.0, 'total' => 30.0])
->and($forecast['days'][2])->toMatchArray(['day' => 20, 'upcoming' => 30.0, 'total' => 30.0])
->and($forecast['days'][3])->toMatchArray(['day' => 25, 'upcoming' => 75.0, 'total' => 75.0]);
});
it('prognostiziert auch ohne bereits erreichte Qualifikationsstufe', function (): void {
// A08: Wer im Monat noch keine Stufe qualifiziert hat, kann die erste durch
// kommende eigene Kundenabo-Punkte trotzdem erreichen. Vorher lieferte die
// Prognose in diesem Zustand gar nichts.
foreach (['sales_volume_points_KP_sum' => 'float', 'payline_points_qual_kp' => 'float', 'qual_user_level_next' => 'text'] as $column => $type) {
if (! Schema::connection('sqlite')->hasColumn('user_businesses', $column)) {
Schema::connection('sqlite')->table('user_businesses', function ($table) use ($column, $type) {
$type === 'float' ? $table->float($column)->nullable() : $table->text($column)->nullable();
});
}
}
$root = pflMakeUser();
pflSeedContext($root, []);
UserBusiness::forceCreate([
'user_id' => $root->id,
'month' => 7,
'year' => 2026,
'version' => 1,
'payline_points' => 0.0,
'payline_points_qual_kp' => 0.0,
'sales_volume_points_KP_sum' => 0.0,
'commission_pp_total' => 0,
'commission_shop_sales' => 0,
'commission_growth_total' => 0,
'qual_user_level' => null, // noch keine Stufe erreicht
'qual_user_level_next' => [
'name' => 'Start', 'paylines' => 1, 'qual_pp' => 0, 'qual_kp' => 50, 'growth_bonus' => 0,
'pr_line_1' => 7, 'pr_line_2' => 0, 'pr_line_3' => 0, 'pr_line_4' => 0,
'pr_line_5' => 0, 'pr_line_6' => 0, 'pr_line_7' => 0, 'pr_line_8' => 0,
],
]);
// Eigenes Kundenabo mit 50 Punkten, faellig vor Monatsende
pflMakeAbo($root->id, 'ot', '2026-07-20', 50);
$forecast = app(PaylineForecastService::class)->levelAndCommissionForecast($root, 7, 2026);
expect($forecast)->not->toBeNull()
->and($forecast['next_name'])->toBe('Start')
->and($forecast['personal_forecast'])->toBe(50.0)
->and($forecast['missing_personal'])->toBe(0.0)
->and($forecast['reached'])->toBeTrue()
->and($forecast['estimated_level_name'])->toBe('Start');
});
it('liefert ohne erreichte und ohne naechste Stufe weiterhin keine Prognose', function (): void {
foreach (['sales_volume_points_KP_sum' => 'float', 'payline_points_qual_kp' => 'float', 'qual_user_level_next' => 'text'] as $column => $type) {
if (! Schema::connection('sqlite')->hasColumn('user_businesses', $column)) {
Schema::connection('sqlite')->table('user_businesses', function ($table) use ($column, $type) {
$type === 'float' ? $table->float($column)->nullable() : $table->text($column)->nullable();
});
}
}
$root = pflMakeUser();
pflSeedContext($root, []);
UserBusiness::forceCreate([
'user_id' => $root->id,
'month' => 7,
'year' => 2026,
'version' => 1,
'payline_points' => 0.0,
'sales_volume_points_KP_sum' => 0.0,
'qual_user_level' => null,
'qual_user_level_next' => null,
]);
expect(app(PaylineForecastService::class)->levelAndCommissionForecast($root, 7, 2026))->toBeNull();
});

View file

@ -252,6 +252,52 @@ it('liefert Abo-Bestaende monatsbezogen: vergangene Monate zeigen den Bestand zu
Carbon\Carbon::setTestNow();
});
it('zaehlt ein systemseitig reaktiviertes Abo wieder zum Bestand (A16)', function (): void {
Carbon\Carbon::setTestNow('2026-09-15');
$root = makeUser();
$anna = makeUser($root->id);
// Ende Juli deaktiviert, Anfang August durch einen Systemschreibpfad
// (Recovery-Zahlung/Retry/externe Erledigung) wieder aktiviert. Ohne
// protokollierte Reaktivierung fiele das Abo ab August dauerhaft aus dem Bestand.
$abo = makeAbo($anna->id, $anna->id, 'ot', '2026-06-01');
$abo->update(['status' => 6, 'active' => false]);
$abo->status_histories()->latest('id')->first()->forceFill(['created_at' => '2026-07-28'])->save();
$abo->update(['status' => 2, 'active' => true]);
$abo->status_histories()->latest('id')->first()->forceFill(['created_at' => '2026-08-03'])->save();
$svc = app(TeamAboOverviewService::class);
expect($svc->teamKpis($root, 7, 2026)['team_customer_abos'])->toBe(0)
->and($svc->teamKpis($root, 8, 2026)['team_customer_abos'])->toBe(1);
Carbon\Carbon::setTestNow();
});
it('wertet eine Reaktivierung in derselben Sekunde als spaeteres Ereignis (A16)', function (): void {
Carbon\Carbon::setTestNow('2026-09-15');
$root = makeUser();
$anna = makeUser($root->id);
// created_at ist sekundengenau: Deaktivierung und Reaktivierung koennen im
// selben Request denselben Zeitstempel tragen. Dann entscheidet die Reihenfolge.
$abo = makeAbo($anna->id, $anna->id, 'ot', '2026-06-01');
$abo->update(['status' => 6, 'active' => false]);
$abo->update(['status' => 2, 'active' => true]);
$abo->status_histories()->update(['created_at' => '2026-07-20 09:00:00']);
$svc = app(TeamAboOverviewService::class);
expect($abo->status_histories()->count())->toBe(2)
->and($svc->teamKpis($root, 7, 2026)['team_customer_abos'])->toBe(1);
Carbon\Carbon::setTestNow();
});
it('zaehlt "+N neu" mit derselben Stichtags-Logik wie den Bestand', function (): void {
Carbon\Carbon::setTestNow('2026-08-15');
@ -431,3 +477,27 @@ it('liefert null, wenn das Mitglied nicht im Team liegt', function (): void {
expect($summary)->toBeNull();
});
it('prognostiziert am letzten Liefertag noch die offenen Ausfuehrungen (A13)', function (): void {
// 25.07., morgens: der Ausfuehrungsjob laeuft um 04:00 — vorher steht die
// Lieferung noch aus und gehoert in die Prognose, nicht auf null.
Carbon\Carbon::setTestNow('2026-07-25 01:00:00');
$root = makeUser();
$anna = makeUser($root->id);
$abo = makeAbo($anna->id, $anna->id, 'ot', '2026-05-01', 40);
$abo->update(['next_date' => '2026-07-25']);
$expectedAt25 = app(TeamAboOverviewService::class)->linesOverview($root, 7, 2026)['totals']['abo_points_expected'];
// Einen Tag spaeter ist das Fenster zu, die Prognose faellt auf das Ist zurueck.
Carbon\Carbon::setTestNow('2026-07-26 01:00:00');
$overview = app(TeamAboOverviewService::class)->linesOverview($root, 7, 2026);
$billedAt26 = $overview['totals']['abo_points_billed'];
expect($expectedAt25)->toBeGreaterThan($billedAt26)
->and($overview['totals']['abo_points_expected'])->toBe($billedAt26);
Carbon\Carbon::setTestNow();
});

View file

@ -125,6 +125,66 @@ it('begrenzt auf Top 3 und sortiert Gleichstaende nach Name', function (): void
->toBe([$carla->id, $anna->id, $zoe->id]);
});
it('beruecksichtigt Gleichstaende jenseits des Auswahlfensters (A11)', function (): void {
// Der Namens-Tiebreak darf nicht an der Groesse des Kandidatenfensters
// haengen: "Alpha" wird zuletzt angelegt und liegt mit 25 anderen gleichauf.
$root = trsMakeUser(null, 'Root', 'Viewer');
for ($i = 0; $i < 25; $i++) {
$zulu = trsMakeUser($root->id, 'Zulu'.str_pad((string) $i, 2, '0', STR_PAD_LEFT), 'Zett');
trsAddVolume($zulu->id, 100, 1);
}
$alpha = trsMakeUser($root->id, 'Alpha', 'Anfang');
trsAddVolume($alpha->id, 100, 1);
$result = trsTop3($root);
expect($result['top_volume'])->toHaveCount(3)
->and($result['top_volume'][0]['user_id'])->toBe($alpha->id)
->and($result['top_volume'][0]['first_name'])->toBe('Alpha');
});
it('fuellt die Top 3 auch auf, wenn viele Kandidaten keinen Account haben (A11)', function (): void {
$root = trsMakeUser(null, 'Root', 'Viewer');
// Kandidaten ohne Account koennen nicht angezeigt werden; sie duerfen die
// Auswahl aber auch nicht verstopfen.
for ($i = 0; $i < 25; $i++) {
$orphan = User::forceCreate([
'email' => 'trs-orphan-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'm_level' => 1,
'm_sponsor' => $root->id,
'payment_account' => now()->addYear(),
]);
trsAddVolume($orphan->id, 500, 1);
}
$berta = trsMakeUser($root->id, 'Berta', 'Bach');
trsAddVolume($berta->id, 100, 1);
$result = trsTop3($root);
expect(array_column($result['top_volume'], 'user_id'))->toBe([$berta->id]);
});
it('sortiert Namensgleichheit reproduzierbar nach user_id (A11)', function (): void {
$root = trsMakeUser(null, 'Root', 'Viewer');
$first = trsMakeUser($root->id, 'Maria', 'Muster');
$second = trsMakeUser($root->id, 'Maria', 'Muster');
$third = trsMakeUser($root->id, 'Maria', 'Muster');
foreach ([$first, $second, $third] as $user) {
trsAddVolume($user->id, 100, 1);
}
expect(array_column(trsTop3($root)['top_volume'], 'user_id'))
->toBe([$first->id, $second->id, $third->id]);
});
it('zaehlt Neupartner je Sponsor nach der Dashboard-Definition', function (): void {
$root = trsMakeUser(null, 'Root', 'Viewer');
$sponsor = trsMakeUser($root->id, 'Sandra', 'Sponsor');
@ -240,3 +300,25 @@ it('rendert die Top-Ergebnisse-Karte nur mit Eintraegen', function (): void {
expect($emptyHtml)->not->toContain(e(__('home.top_results_title')));
});
it('haelt "Top 3 neue Abos" fuer abgeschlossene Monate stabil, auch wenn Abos spaeter enden', function (): void {
// Regression: die Liste zaehlte ueber activeAbo() und schrumpfte damit
// rueckwirkend, sobald ein Abo endete — anders als die Top-3-Neupartner
// daneben, die ueber newPartnerIn() bereits stichtagsstabil sind.
$root = trsMakeUser(null, 'Root', 'Viewer');
$anna = trsMakeUser($root->id, 'Anna', 'Alpha');
// Im Juli gestartet, erst im August gekuendigt: gehoert in die Juli-Zahl.
trsMakeAbo($anna->id, 'ot', '2026-07-10', active: false)
->update(['cancel_date' => '2026-08-05']);
trsMakeAbo($anna->id, 'ot', '2026-07-20');
// Im August gestartet: gehoert nicht in die Juli-Zahl.
trsMakeAbo($anna->id, 'ot', '2026-08-02');
Cache::flush();
$result = app(TopResultsService::class)->monthlyTop3($root, 7, 2026);
expect($result['top_new_abos'])->toHaveCount(1)
->and($result['top_new_abos'][0]['first_name'])->toBe('Anna')
->and($result['top_new_abos'][0]['value'])->toBe(2);
});