Team-Statistik = Team-Struktur (Provisionsbaum, Umschalter) und Laufstand-Fix in user_sales_volumes
K11 aus dem September-Protokoll (Tickets C1 und C2 im Entwicklungsplan), live seit 14.09.2026. C1 – Statistik rechnet wie die Provision - Standard-Baum ist der Provisionsbaum (Mitglieder wie TreeCalcBot: Mitgliedsebene, Abrechnungskonto, active_date; ausgefilterte Knoten kappen ihren Unterbaum, max. 21 Linien). Gesamte Downline bleibt per Umschalter erreichbar, Wahl in der Session. - Punkte-Definition: Provisionspunkte (TP + Shop), reine Karrierepunkte ausgewiesen statt eingerechnet, negative Monatssalden je User wie in der Provision auf 0 gekappt. - Abgeschlossene Monate nehmen die Mitglieder je Linie aus dem gespeicherten Provisionslauf (user_businesses), Snapshots je Baum-Definition (Payload-Version 3, Migration). - Struktur-Modal: Gesamt aus den Linien-Punkten mit Dezimalen statt der int-Spalte total_pp. - Messlauf statistics:compare-trees (Downline / Provisionsbaum / Struktur je VIP, Laufstand-Drift). C2 – Laufstand-Race (Provision zu niedrig bei zeitgleichen Buchungen) - Neuberechnung liest die Zeilen des Beraters sperrend (FOR UPDATE), Index (user_id, year, month). - Paid-Pfad der Payone- und Stripe-Webhooks als DB::transaction mit 3 Anlaeufen; Deadlock und Lock-Timeout aus der Rechnungserstellung werden nicht mehr geschluckt. - Sicherheitsnetz sales:repair-running-totals taeglich 02:45 vor dem Provisionslauf; abgeschlossene Monate werden nicht angefasst (Entscheid 14.09.: keine Nachzahlung fuer 06/08). Doku: Entwicklungsplan zum Protokoll, Deploy-Notiz 14.09. (inkl. Migrations-Stolperstein: Migrationen tragen Werte als Literale, nicht als Konstanten aus dem Anwendungscode). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
685f7a90b9
commit
96545364f1
33 changed files with 2321 additions and 139 deletions
|
|
@ -19,6 +19,7 @@ class BackofficeStoreStatisticsSnapshots extends Command
|
|||
{--user= : Nur einen bestimmten User berechnen (user_id)}
|
||||
{--month= : Nur einen bestimmten Monat berechnen}
|
||||
{--year= : Nur ein bestimmtes Jahr berechnen}
|
||||
{--tree= : Nur eine Baum-Definition (commission|downline), Standard: beide}
|
||||
{--force : Bereits vorhandene Snapshots überschreiben}';
|
||||
|
||||
/**
|
||||
|
|
@ -48,6 +49,9 @@ class BackofficeStoreStatisticsSnapshots extends Command
|
|||
{
|
||||
$months = $this->monthsToStore();
|
||||
$force = (bool) $this->option('force');
|
||||
$trees = $this->option('tree')
|
||||
? [BackofficeDashboardService::normalizeTree((string) $this->option('tree'))]
|
||||
: BackofficeDashboardService::trees();
|
||||
|
||||
if ($months === []) {
|
||||
$this->info('Keine abgeschlossenen Monate zum Speichern.');
|
||||
|
|
@ -65,7 +69,7 @@ class BackofficeStoreStatisticsSnapshots extends Command
|
|||
}
|
||||
|
||||
$users = $userQuery->get();
|
||||
$this->info('Berechne Backoffice-Snapshots fuer '.$users->count().' User und '.count($months).' Monate...');
|
||||
$this->info('Berechne Backoffice-Snapshots fuer '.$users->count().' User, '.count($months).' Monate und '.count($trees).' Baum-Definition(en)...');
|
||||
|
||||
$bar = $this->output->createProgressBar($users->count());
|
||||
$bar->start();
|
||||
|
|
@ -75,20 +79,23 @@ class BackofficeStoreStatisticsSnapshots extends Command
|
|||
|
||||
foreach ($users as $user) {
|
||||
foreach ($months as [$year, $month]) {
|
||||
$exists = BackofficeStatisticsSnapshot::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('year', $year)
|
||||
->where('month', $month)
|
||||
->exists();
|
||||
foreach ($trees as $tree) {
|
||||
$exists = BackofficeStatisticsSnapshot::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('year', $year)
|
||||
->where('month', $month)
|
||||
->where('tree', $tree)
|
||||
->exists();
|
||||
|
||||
if ($exists && ! $force) {
|
||||
$skipped++;
|
||||
if ($exists && ! $force) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
continue;
|
||||
}
|
||||
|
||||
$dashboardService->storeSnapshot($user, $month, $year, $tree);
|
||||
$stored++;
|
||||
}
|
||||
|
||||
$dashboardService->storeSnapshot($user, $month, $year);
|
||||
$stored++;
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
|
|
|
|||
110
app/Console/Commands/SalesRepairRunningTotals.php
Normal file
110
app/Console/Commands/SalesRepairRunningTotals.php
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\BusinessPlan\SalesVolumeRunningTotalService;
|
||||
use App\User;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Sicherheitsnetz gegen Laufstand-Drift (Ticket C2): laeuft taeglich um 02:45,
|
||||
* also vor dem Provisionslauf `business:store-optimized` um 03:00, und rechnet
|
||||
* die Laufstaende aller User neu, deren letzte Zeile in `user_sales_volumes`
|
||||
* nicht zur Zeilensumme passt. Ohne Argumente werden der laufende Monat und —
|
||||
* solange er noch nicht abgerechnet ist — der Vormonat geprueft.
|
||||
*
|
||||
* Hintergrund: {@see SalesVolumeRunningTotalService}.
|
||||
*/
|
||||
class SalesRepairRunningTotals extends Command
|
||||
{
|
||||
protected $signature = 'sales:repair-running-totals
|
||||
{month? : Monat (Standard: laufender Monat und, falls offen, Vormonat)}
|
||||
{year? : Jahr}
|
||||
{--dry-run : Nur anzeigen, nichts schreiben}
|
||||
{--force : Auch einen bereits abgerechneten Monat neu rechnen (Nachberechnung!)}';
|
||||
|
||||
protected $description = 'Laufstaende in user_sales_volumes gegen die Zeilensumme pruefen und abweichende User neu berechnen';
|
||||
|
||||
public function handle(SalesVolumeRunningTotalService $service): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$months = $this->months($service);
|
||||
|
||||
if ($months === null) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
foreach ($months as ['month' => $month, 'year' => $year]) {
|
||||
$drift = $dryRun
|
||||
? $service->detectDrift($month, $year)
|
||||
: $service->repair($month, $year);
|
||||
|
||||
$label = sprintf('%02d/%d', $month, $year);
|
||||
|
||||
if ($drift === []) {
|
||||
$this->info("Laufstaende {$label}: keine Abweichung.");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$users = User::query()->whereIn('id', array_keys($drift))->get()->keyBy('id');
|
||||
|
||||
$this->warn(sprintf(
|
||||
'Laufstaende %s: %d User mit Drift%s',
|
||||
$label,
|
||||
count($drift),
|
||||
$dryRun ? ' (DRY-RUN, nichts geschrieben)' : ', neu berechnet'
|
||||
));
|
||||
$this->table(
|
||||
['User', 'E-Mail', 'Zeilensumme', 'Laufstand vorher', 'Diff'],
|
||||
collect($drift)->map(fn (array $values, int $userId) => [
|
||||
$userId,
|
||||
$users->get($userId)?->email ?? '-',
|
||||
number_format($values['rows'], 2, ',', '.'),
|
||||
number_format($values['running'], 2, ',', '.'),
|
||||
number_format($values['running'] - $values['rows'], 2, ',', '.'),
|
||||
])->values()->all()
|
||||
);
|
||||
|
||||
\Log::channel('cron')->warning('sales:repair-running-totals: Laufstand-Drift', [
|
||||
'month' => $month,
|
||||
'year' => $year,
|
||||
'dry_run' => $dryRun,
|
||||
'users' => $drift,
|
||||
]);
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{month: int, year: int}>|null
|
||||
*/
|
||||
private function months(SalesVolumeRunningTotalService $service): ?array
|
||||
{
|
||||
if (! $this->argument('month')) {
|
||||
return $service->monthsToGuard();
|
||||
}
|
||||
|
||||
$month = (int) $this->argument('month');
|
||||
$year = (int) ($this->argument('year') ?: now()->year);
|
||||
|
||||
if ($month < 1 || $month > 12 || $year < 2020) {
|
||||
$this->error('Ungueltiger Monat/Jahr.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (SalesVolumeRunningTotalService::isMonthClosed($month, $year) && ! $this->option('dry-run') && ! $this->option('force')) {
|
||||
$this->error(sprintf(
|
||||
'Monat %02d/%d ist abgerechnet — die Laufstaende sind so in die Provision eingegangen. Neu rechnen nur mit --force (aendert keine Gutschriften, nur die Datenbasis).',
|
||||
$month,
|
||||
$year
|
||||
));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return [['month' => $month, 'year' => $year]];
|
||||
}
|
||||
}
|
||||
282
app/Console/Commands/StatisticsCompareTrees.php
Normal file
282
app/Console/Commands/StatisticsCompareTrees.php
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Http\Controllers\ModalController;
|
||||
use App\Services\Backoffice\BackofficeDashboardService;
|
||||
use App\Services\BusinessPlan\SalesVolumeRunningTotalService;
|
||||
use App\Services\BusinessPlan\TreeCalcBotOptimized;
|
||||
use App\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
/**
|
||||
* Messlauf zu K11 („Team-Statistik und Team-Struktur zeigen andere Zahlen“):
|
||||
* stellt fuer jeden VIP die beiden Baum-Definitionen der Statistik und die
|
||||
* Punkte der Team-Struktur (TreeCalcBot) nebeneinander — je Linie und in Summe.
|
||||
*
|
||||
* Zusaetzlich prueft er die Laufstaende in `user_sales_volumes`: der TreeCalcBot
|
||||
* liest je User die kumulativen Spalten `month_TP_points + month_shop_points`
|
||||
* der letzten Zeile, die Statistik summiert die Einzelzeilen. Laufen zwei
|
||||
* Abo-Ausfuehrungen fast zeitgleich, ueberholen sich die Laufstaende und die
|
||||
* letzte Zeile ist zu niedrig — dann fehlen der Struktur (und damit der
|
||||
* Provision!) Punkte, die in der Statistik richtig stehen. `--repair-running-totals`
|
||||
* rechnet die Laufstaende dieser User mit der Anwendungsfunktion neu.
|
||||
*
|
||||
* Im laufenden Monat rechnet die Team-Struktur live aus diesen Spalten — nach der
|
||||
* Reparatur stimmt sie sofort (Provisions-Kachel: 6-h-Cache). Fuer einen
|
||||
* ABGESCHLOSSENEN Monat ist der Provisionslauf (`business:store-optimized`, inkl.
|
||||
* Gutschriften) bereits gelaufen; ihn zu wiederholen ist keine Reparatur, sondern
|
||||
* ein neuer Abrechnungslauf (mit `--clear` entstehen doppelte Gutschriften). Die
|
||||
* Differenz ist dann als Nachzahlung fachlich zu entscheiden.
|
||||
*/
|
||||
class StatisticsCompareTrees extends Command
|
||||
{
|
||||
protected $signature = 'statistics:compare-trees
|
||||
{month? : Monat (Standard: aktueller Monat)}
|
||||
{year? : Jahr (Standard: aktuelles Jahr)}
|
||||
{--user=* : Nur diese User-IDs (Standard: alle VIPs)}
|
||||
{--lines : Zusaetzlich jede Linie einzeln ausgeben}
|
||||
{--live-structure : Team-Struktur live rechnen statt den gespeicherten Nachtlauf zu lesen}
|
||||
{--repair-running-totals : Laufstaende abweichender User neu berechnen (schreibt in user_sales_volumes)}';
|
||||
|
||||
protected $description = 'Vergleicht Team-Statistik (Provisionsbaum / Downline) mit der Team-Struktur je VIP';
|
||||
|
||||
public function handle(BackofficeDashboardService $dashboardService): int
|
||||
{
|
||||
$month = (int) ($this->argument('month') ?: now()->month);
|
||||
$year = (int) ($this->argument('year') ?: now()->year);
|
||||
|
||||
if ($month < 1 || $month > 12 || $year < 2020) {
|
||||
$this->error('Ungueltiger Monat/Jahr.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$users = $this->vipUsers();
|
||||
|
||||
if ($users->isEmpty()) {
|
||||
$this->warn('Keine VIP-User gefunden.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$storedStructure = TreeCalcBotOptimized::isFromStored($month, $year);
|
||||
$this->info(sprintf(
|
||||
'Vergleich %02d/%d fuer %d VIP(s) — Team-Struktur: %s',
|
||||
$month,
|
||||
$year,
|
||||
$users->count(),
|
||||
$this->option('live-structure')
|
||||
? 'live gerechnet'
|
||||
: ($storedStructure ? 'gespeicherter Nachtlauf vom '.$storedStructure->updated_at?->format('d.m.Y H:i') : 'kein gespeicherter Lauf, live gerechnet')
|
||||
));
|
||||
$this->newLine();
|
||||
|
||||
$summaryRows = [];
|
||||
$lineRows = [];
|
||||
$commissionUserIds = [];
|
||||
|
||||
foreach ($users as $user) {
|
||||
$dashboardService->flushLineBucketsMemo((int) $user->id);
|
||||
|
||||
$downline = $dashboardService->liveOverview($user, $month, $year, BackofficeDashboardService::TREE_DOWNLINE);
|
||||
$commission = $dashboardService->liveOverview($user, $month, $year, BackofficeDashboardService::TREE_COMMISSION);
|
||||
$structure = $this->structurePoints($user, $month, $year);
|
||||
|
||||
$commissionUserIds = array_merge($commissionUserIds, $commission['totals']['user_ids'] ?? []);
|
||||
|
||||
$commissionTotal = (float) $commission['totals']['total_points'];
|
||||
$summaryRows[] = [
|
||||
$user->id,
|
||||
$this->userLabel($user),
|
||||
(int) $downline['totals']['consultants'],
|
||||
(int) $commission['totals']['consultants'],
|
||||
count($downline['totals']['user_ids'] ?? []) - count($commission['totals']['user_ids'] ?? []),
|
||||
$this->fmt((float) $downline['totals']['total_points']),
|
||||
$this->fmt($commissionTotal),
|
||||
$structure === null ? 'n/a' : $this->fmt($structure['total']),
|
||||
$structure === null ? 'n/a' : $this->fmt($commissionTotal - $structure['total']),
|
||||
$this->fmt((float) ($commission['non_payline_points'] ?? 0)),
|
||||
$this->fmt((float) ($commission['storno_floor_points'] ?? 0)),
|
||||
];
|
||||
|
||||
if ($this->option('lines')) {
|
||||
$maxLine = max(
|
||||
array_keys($downline['lines']) ?: [0],
|
||||
array_keys($commission['lines']) ?: [0],
|
||||
$structure === null ? [0] : (array_keys($structure['lines']) ?: [0])
|
||||
);
|
||||
|
||||
for ($line = 1; $line <= $maxLine; $line++) {
|
||||
$commissionLine = (float) ($commission['lines'][$line]['total_points'] ?? 0);
|
||||
$structureLine = $structure === null ? null : (float) ($structure['lines'][$line] ?? 0);
|
||||
|
||||
$lineRows[] = [
|
||||
$user->id,
|
||||
$line,
|
||||
(int) ($downline['lines'][$line]['consultants'] ?? 0),
|
||||
(int) ($commission['lines'][$line]['consultants'] ?? 0),
|
||||
$this->fmt((float) ($downline['lines'][$line]['total_points'] ?? 0)),
|
||||
$this->fmt($commissionLine),
|
||||
$structureLine === null ? 'n/a' : $this->fmt($structureLine),
|
||||
$structureLine === null ? 'n/a' : $this->fmt($commissionLine - $structureLine),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['ID', 'VIP', 'Berater DL', 'Berater PB', 'nicht im PB', 'Punkte DL', 'Punkte PB', 'Struktur', 'Diff PB-Struktur', 'nur KP (nicht enth.)', 'Storno-Saldo (nicht abgez.)'],
|
||||
$summaryRows
|
||||
);
|
||||
$this->line('DL = gesamte Downline (fruehere Statistik-Sicht) · PB = Provisionsbaum (neue Standard-Sicht, wie Team-Struktur)');
|
||||
|
||||
if ($lineRows !== []) {
|
||||
$this->newLine();
|
||||
$this->table(['ID', 'Linie', 'Berater DL', 'Berater PB', 'Punkte DL', 'Punkte PB', 'Struktur', 'Diff'], $lineRows);
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->reportRunningTotalDrift(array_values(array_unique(array_map('intval', $commissionUserIds))), $month, $year);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, User>
|
||||
*/
|
||||
private function vipUsers()
|
||||
{
|
||||
$query = User::query()
|
||||
->with('account')
|
||||
->whereNull('deleted_at')
|
||||
->orderBy('id');
|
||||
|
||||
$userIds = array_filter(array_map('intval', (array) $this->option('user')));
|
||||
|
||||
if ($userIds !== []) {
|
||||
return $query->whereIn('id', $userIds)->get();
|
||||
}
|
||||
|
||||
return $query->where('admin', '>=', 1)->where('admin', '<', 4)->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Linien-Punkte der Team-Struktur, so wie das Berater-Detail (Modal „Linie /
|
||||
* Punkte / Provision" auf der Struktur-Seite, {@see ModalController})
|
||||
* sie zeigt: gespeicherter Nachtlauf (`user_businesses`), sofern vorhanden;
|
||||
* sonst bzw. mit --live-structure live gerechnet.
|
||||
*
|
||||
* @return array{lines: array<int, float>, total: float}|null
|
||||
*/
|
||||
private function structurePoints(User $user, int $month, int $year): ?array
|
||||
{
|
||||
try {
|
||||
$live = (bool) $this->option('live-structure');
|
||||
$bot = new TreeCalcBotOptimized($month, $year, 'member', $live);
|
||||
$bot->initBusinesslUserDetail($user, $live);
|
||||
|
||||
$lines = [];
|
||||
$total = 0.0;
|
||||
|
||||
for ($line = 1; $line <= BackofficeDashboardService::COMMISSION_TREE_MAX_LINES; $line++) {
|
||||
$points = (float) $bot->getKeybyLine($line, 'points');
|
||||
|
||||
if ($points === 0.0 && ! $this->structureHasLine($bot, $line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lines[$line] = $points;
|
||||
$total += $points;
|
||||
}
|
||||
|
||||
return ['lines' => $lines, 'total' => $total];
|
||||
} catch (\Throwable $e) {
|
||||
$this->warn("Team-Struktur fuer User {$user->id} nicht berechenbar: ".$e->getMessage());
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function structureHasLine(TreeCalcBotOptimized $bot, int $line): bool
|
||||
{
|
||||
$businessUser = $bot->__get('business_user');
|
||||
$lines = $businessUser?->business_lines;
|
||||
|
||||
if ($lines === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isset($lines[$line]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Laufstand-Drift der Provisionsbaum-Mitglieder ({@see SalesVolumeRunningTotalService}).
|
||||
*
|
||||
* @param int[] $userIds
|
||||
*/
|
||||
private function reportRunningTotalDrift(array $userIds, int $month, int $year): void
|
||||
{
|
||||
if ($userIds === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$service = app(SalesVolumeRunningTotalService::class);
|
||||
$drift = $service->detectDrift($month, $year, $userIds);
|
||||
|
||||
if ($drift === []) {
|
||||
$this->info('Laufstaende: keine Abweichung zwischen Zeilensumme und kumulierten Spalten.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$users = User::query()->whereIn('id', array_keys($drift))->get()->keyBy('id');
|
||||
|
||||
$this->warn(count($drift).' User mit Laufstand-Drift (Struktur/Provision rechnet mit dem zu niedrigen bzw. zu hohen Laufstand):');
|
||||
$this->table(
|
||||
['User', 'E-Mail', 'Zeilensumme (Statistik)', 'Laufstand (Struktur)', 'Diff'],
|
||||
collect($drift)->map(fn (array $values, int $userId) => [
|
||||
$userId,
|
||||
$users->get($userId)?->email ?? '-',
|
||||
$this->fmt($values['rows']),
|
||||
$this->fmt($values['running']),
|
||||
$this->fmt($values['running'] - $values['rows']),
|
||||
])->values()->all()
|
||||
);
|
||||
|
||||
$isClosedMonth = SalesVolumeRunningTotalService::isMonthClosed($month, $year);
|
||||
|
||||
if (! $this->option('repair-running-totals')) {
|
||||
$this->line('Reparatur der Laufstaende: --repair-running-totals (taeglich 02:45 automatisch: sales:repair-running-totals)');
|
||||
|
||||
if ($isClosedMonth) {
|
||||
$this->warn('Abgeschlossener Monat: der Provisionslauf hat mit diesem Laufstand abgerechnet. Die Reparatur aendert nur die Datenbasis, keine Gutschriften — business:store-optimized NICHT erneut ausfuehren (doppelte Gutschriften).');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$service->repair($month, $year, array_keys($drift));
|
||||
|
||||
$this->info('Laufstaende von '.count($drift).' User(n) neu berechnet.');
|
||||
|
||||
if ($isClosedMonth) {
|
||||
$this->warn('Abgeschlossener Monat: gespeicherte Struktur und Gutschriften bleiben unveraendert.');
|
||||
} else {
|
||||
$this->line('Laufender Monat: die Team-Struktur rechnet live und zeigt den korrigierten Stand sofort (php artisan cache:clear fuer die Provisions-Kachel).');
|
||||
}
|
||||
}
|
||||
|
||||
private function userLabel(User $user): string
|
||||
{
|
||||
$name = trim(($user->account?->first_name ?? '').' '.($user->account?->last_name ?? ''));
|
||||
|
||||
return $name !== '' ? $name : (string) $user->email;
|
||||
}
|
||||
|
||||
private function fmt(float $value): string
|
||||
{
|
||||
return number_format($value, 2, ',', '.');
|
||||
}
|
||||
}
|
||||
|
|
@ -84,6 +84,11 @@ class Kernel extends ConsoleKernel
|
|||
// Monats nachziehen (Callback-Aussetzer), vor business:store-optimized um 03:00
|
||||
$schedule->command('sales:repair-sponsor-vp')->dailyAt('02:30');
|
||||
|
||||
// Laufstand-Drift (Ticket C2): letzte Zeile in user_sales_volumes gegen die
|
||||
// Zeilensumme pruefen und abweichende User neu rechnen — vor dem
|
||||
// Provisionslauf um 03:00, damit er nie mit einem verschobenen Laufstand rechnet
|
||||
$schedule->command('sales:repair-running-totals')->dailyAt('02:45');
|
||||
|
||||
// Incentive: Punkteberechnung täglich nach business:store-optimized
|
||||
$schedule->command('incentive:calculate')->dailyAt('05:00');
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ class BackofficeStatisticsController extends Controller
|
|||
|
||||
private const SESSION_YEAR_KEY = 'backoffice_statistics_year';
|
||||
|
||||
private const SESSION_TREE_KEY = 'backoffice_statistics_tree';
|
||||
|
||||
public function __construct(
|
||||
private BackofficeDashboardService $dashboardService,
|
||||
private BackofficeDrilldownService $drilldownService,
|
||||
|
|
@ -33,8 +35,9 @@ class BackofficeStatisticsController extends Controller
|
|||
}
|
||||
|
||||
[$selectedMonth, $selectedYear] = $this->selectedPeriod($request);
|
||||
$selectedTree = $this->selectedTree($request);
|
||||
$startTime = microtime(true);
|
||||
$statistics = $this->dashboardService->overview($request->user(), $selectedMonth, $selectedYear);
|
||||
$statistics = $this->dashboardService->overview($request->user(), $selectedMonth, $selectedYear, $selectedTree);
|
||||
$performance = [
|
||||
'duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
|
||||
'source' => $statistics['_meta']['source'] ?? 'live',
|
||||
|
|
@ -45,6 +48,8 @@ class BackofficeStatisticsController extends Controller
|
|||
return view('user.backoffice.statistics.index', [
|
||||
'selectedMonth' => $selectedMonth,
|
||||
'selectedYear' => $selectedYear,
|
||||
'selectedTree' => $selectedTree,
|
||||
'treeLabels' => BackofficeDashboardService::treeLabels(),
|
||||
'filterMonths' => HTMLHelper::getTransMonths(),
|
||||
'filterYears' => HTMLHelper::getYearRange(2022),
|
||||
'statistics' => $statistics,
|
||||
|
|
@ -77,6 +82,7 @@ class BackofficeStatisticsController extends Controller
|
|||
}
|
||||
|
||||
[$selectedMonth, $selectedYear] = $this->selectedPeriod($request);
|
||||
$selectedTree = $this->selectedTree($request);
|
||||
$line = (int) $request->get('line', 1);
|
||||
$metric = (string) $request->get('metric', 'consultants');
|
||||
$onlyNew = $request->boolean('only_new');
|
||||
|
|
@ -84,7 +90,8 @@ class BackofficeStatisticsController extends Controller
|
|||
return view('user.backoffice.statistics.details', [
|
||||
'selectedMonth' => $selectedMonth,
|
||||
'selectedYear' => $selectedYear,
|
||||
'details' => $this->drilldownService->details($request->user(), $line, $metric, $selectedMonth, $selectedYear, $onlyNew),
|
||||
'selectedTree' => $selectedTree,
|
||||
'details' => $this->drilldownService->details($request->user(), $line, $metric, $selectedMonth, $selectedYear, $onlyNew, $selectedTree),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -95,10 +102,11 @@ class BackofficeStatisticsController extends Controller
|
|||
}
|
||||
|
||||
[$selectedMonth, $selectedYear] = $this->selectedPeriod($request);
|
||||
$selectedTree = $this->selectedTree($request);
|
||||
$line = (int) $request->get('line', 1);
|
||||
$metric = (string) $request->get('metric', 'consultants');
|
||||
$onlyNew = $request->boolean('only_new');
|
||||
$details = $this->drilldownService->details($request->user(), $line, $metric, $selectedMonth, $selectedYear, $onlyNew);
|
||||
$details = $this->drilldownService->details($request->user(), $line, $metric, $selectedMonth, $selectedYear, $onlyNew, $selectedTree);
|
||||
$filename = sprintf(
|
||||
'backoffice-statistik-%s%s-linie-%s-%02d-%d.csv',
|
||||
$metric,
|
||||
|
|
@ -134,8 +142,9 @@ class BackofficeStatisticsController extends Controller
|
|||
}
|
||||
|
||||
[$selectedMonth, $selectedYear] = $this->selectedPeriod($request);
|
||||
$statistics = $this->dashboardService->overview($request->user(), $selectedMonth, $selectedYear);
|
||||
$filename = sprintf('backoffice-statistik-uebersicht-%02d-%d.csv', $selectedMonth, $selectedYear);
|
||||
$selectedTree = $this->selectedTree($request);
|
||||
$statistics = $this->dashboardService->overview($request->user(), $selectedMonth, $selectedYear, $selectedTree);
|
||||
$filename = sprintf('backoffice-statistik-uebersicht-%s-%02d-%d.csv', $selectedTree, $selectedMonth, $selectedYear);
|
||||
|
||||
return response()->streamDownload(function () use ($statistics): void {
|
||||
$output = fopen('php://output', 'w');
|
||||
|
|
@ -172,6 +181,22 @@ class BackofficeStatisticsController extends Controller
|
|||
return [$selectedMonth, $selectedYear];
|
||||
}
|
||||
|
||||
/**
|
||||
* Baum-Definition (Provisionsbaum / gesamte Downline) — wie Monat und Jahr in
|
||||
* der Session gemerkt, damit Detaillisten und Exporte dieselbe Sicht nutzen wie
|
||||
* die Uebersicht, aus der sie aufgerufen wurden.
|
||||
*/
|
||||
private function selectedTree(Request $request): string
|
||||
{
|
||||
$tree = BackofficeDashboardService::normalizeTree(
|
||||
(string) $request->get('tree', session(self::SESSION_TREE_KEY, BackofficeDashboardService::TREE_COMMISSION))
|
||||
);
|
||||
|
||||
session([self::SESSION_TREE_KEY => $tree]);
|
||||
|
||||
return $tree;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ class BackofficeStatisticsSnapshot extends Model
|
|||
'user_id',
|
||||
'year',
|
||||
'month',
|
||||
'tree',
|
||||
'payload',
|
||||
'calculated_at',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -440,58 +440,27 @@ class PaylineForecastService
|
|||
*
|
||||
* 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".
|
||||
* dem Provisionsbaum der Statistik nach
|
||||
* ({@see BackofficeDashboardService::commissionLineBuckets()} — dieselbe
|
||||
* Filter- und Kapp-Regel wie {@see BusinessUserItemOptimized::readParentsBusinessUsers()}).
|
||||
* Ohne denselben Filter stünden auf einer Seite zwei verschiedene
|
||||
* "fehlen noch X Punkte".
|
||||
*
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function lineByUserId(User $user, int $month, int $year): array
|
||||
{
|
||||
$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 ($this->dashboardService->commissionLineBuckets($user->id, $month, $year) as $line => $users) {
|
||||
foreach ($users as $member) {
|
||||
$memberId = (int) $member->id;
|
||||
|
||||
if (isset($pruned[(int) $member->m_sponsor]) || ! $this->countsInCommissionTree($member, $endOfMonth)) {
|
||||
$pruned[$memberId] = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$map[$memberId] = (int) $line;
|
||||
$map[(int) $member->id] = (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.
|
||||
|
|
|
|||
|
|
@ -5,9 +5,12 @@ namespace App\Services\Backoffice;
|
|||
use App\Models\BackofficeStatisticsSnapshot;
|
||||
use App\Models\ShoppingOrderItem;
|
||||
use App\Models\UserAbo;
|
||||
use App\Models\UserBusiness;
|
||||
use App\Models\UserBusinessStructure;
|
||||
use App\Models\UserLevel;
|
||||
use App\Models\UserSalesVolume;
|
||||
use App\Providers\AppServiceProvider;
|
||||
use App\Services\BusinessPlan\BusinessUserItemOptimized;
|
||||
use App\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
|
@ -47,8 +50,33 @@ class BackofficeDashboardService
|
|||
* 2 — Linienzeilen tragen zusaetzlich `abo_ids`, damit die Detaillisten eines
|
||||
* abgeschlossenen Monats aus demselben eingefrorenen Stand kommen wie die
|
||||
* Zahl darueber.
|
||||
* 3 — Payload traegt `tree` (Baum-Definition) und `storno_floor_points`;
|
||||
* `total_points` sind seitdem die Provisionspunkte (TP + Shop, Knotenregel
|
||||
* des Provisionsbaums) statt der Zeilensumme inkl. Nur-KP-Buchungen.
|
||||
*/
|
||||
public const PAYLOAD_VERSION = 2;
|
||||
public const PAYLOAD_VERSION = 3;
|
||||
|
||||
/**
|
||||
* Baum-Definitionen der Statistik (Kundenentscheid 2026-09-14, K11/F4):
|
||||
*
|
||||
* - `commission` (Standard): der Provisionsbaum — dieselben Mitglieder, mit denen
|
||||
* der TreeCalcBot die Team-Struktur und die Provision rechnet. Damit zeigen
|
||||
* Team-Statistik und Team-Struktur dieselben Punkte.
|
||||
* - `downline`: die komplette Downline (jeder nicht geloeschte Teampartner,
|
||||
* auch ohne Mitgliedsebene/Abrechnungskonto, ohne Kappen des Unterbaums) —
|
||||
* die fruehere Standardsicht, per Umschalter weiterhin verfuegbar, damit
|
||||
* z. B. noch nicht freigeschaltete Partner nachverfolgbar bleiben.
|
||||
*/
|
||||
public const TREE_COMMISSION = 'commission';
|
||||
|
||||
public const TREE_DOWNLINE = 'downline';
|
||||
|
||||
/**
|
||||
* Linien-Obergrenze des Provisionsbaums: {@see BusinessUserItemOptimized::readParentsBusinessUsers()}
|
||||
* bricht bei Tiefe > 20 ab; der Knoten auf Tiefe 20 laedt seine Kinder also noch —
|
||||
* das ist Linie 21.
|
||||
*/
|
||||
public const COMMISSION_TREE_MAX_LINES = 21;
|
||||
|
||||
private const MAX_DEPTH_SAFETY_LIMIT = 100;
|
||||
|
||||
|
|
@ -65,6 +93,42 @@ class BackofficeDashboardService
|
|||
*/
|
||||
private array $lineBucketsMemo = [];
|
||||
|
||||
/**
|
||||
* Memo des Provisionsbaums je Root und Monat (das Monatsende entscheidet ueber
|
||||
* `active_date`), abgeleitet aus {@see $lineBucketsMemo}.
|
||||
*
|
||||
* @var array<string, array<int, Collection<int, User>>>
|
||||
*/
|
||||
private array $commissionLineBucketsMemo = [];
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public static function trees(): array
|
||||
{
|
||||
return [self::TREE_COMMISSION, self::TREE_DOWNLINE];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function treeLabels(): array
|
||||
{
|
||||
return [
|
||||
self::TREE_COMMISSION => 'Provisionsbaum (wie Team-Struktur)',
|
||||
self::TREE_DOWNLINE => 'Gesamte Downline (inkl. nicht provisionsberechtigte Berater)',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Unbekannte oder leere Werte (alte Links, manipulierte Parameter) fallen auf
|
||||
* den Standard zurueck.
|
||||
*/
|
||||
public static function normalizeTree(?string $tree): string
|
||||
{
|
||||
return in_array($tree, self::trees(), true) ? $tree : self::TREE_COMMISSION;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
|
|
@ -88,13 +152,16 @@ class BackofficeDashboardService
|
|||
/**
|
||||
* @return array{month: int, year: int, metric_labels: array<string, string>, lines: array<int, array<string, mixed>>, totals: array<string, mixed>}
|
||||
*/
|
||||
public function overview(User $user, int $month, int $year): array
|
||||
public function overview(User $user, int $month, int $year, string $tree = self::TREE_COMMISSION): array
|
||||
{
|
||||
$tree = self::normalizeTree($tree);
|
||||
|
||||
if ($this->isClosedMonth($month, $year)) {
|
||||
$snapshot = BackofficeStatisticsSnapshot::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('month', $month)
|
||||
->where('year', $year)
|
||||
->where('tree', $tree)
|
||||
->first();
|
||||
|
||||
if ($snapshot) {
|
||||
|
|
@ -105,12 +172,25 @@ class BackofficeDashboardService
|
|||
return $this->withMeta(
|
||||
$snapshot->payload,
|
||||
'snapshot',
|
||||
$tree,
|
||||
$snapshot->calculated_at?->format('d.m.Y H:i')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->withMeta($this->buildOverview($user, $month, $year), 'live');
|
||||
return $this->withMeta($this->buildOverview($user, $month, $year, $tree), 'live', $tree);
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Monatsstand immer frisch aus den heutigen Daten — auch fuer abgeschlossene
|
||||
* Monate (Mess-/Vergleichslaeufe, Snapshot-Aufbau). Die Seite selbst geht ueber
|
||||
* {@see overview()}, damit eingefrorene Monate eingefroren bleiben.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function liveOverview(User $user, int $month, int $year, string $tree = self::TREE_COMMISSION): array
|
||||
{
|
||||
return $this->withMeta($this->buildOverview($user, $month, $year, self::normalizeTree($tree)), 'live', $tree);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -121,26 +201,36 @@ class BackofficeDashboardService
|
|||
{
|
||||
if ($rootUserId === null) {
|
||||
$this->lineBucketsMemo = [];
|
||||
$this->commissionLineBucketsMemo = [];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
unset($this->lineBucketsMemo[$rootUserId]);
|
||||
|
||||
foreach (array_keys($this->commissionLineBucketsMemo) as $key) {
|
||||
if (str_starts_with($key, $rootUserId.':')) {
|
||||
unset($this->commissionLineBucketsMemo[$key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function storeSnapshot(User $user, int $month, int $year): BackofficeStatisticsSnapshot
|
||||
public function storeSnapshot(User $user, int $month, int $year, string $tree = self::TREE_COMMISSION): BackofficeStatisticsSnapshot
|
||||
{
|
||||
$tree = self::normalizeTree($tree);
|
||||
|
||||
// Frisch traversieren: der Snapshot-Cron läuft lange und über viele User —
|
||||
// ein Memo aus früheren Aufrufen desselben Roots wäre potenziell veraltet.
|
||||
$this->flushLineBucketsMemo((int) $user->id);
|
||||
|
||||
$payload = $this->buildOverview($user, $month, $year);
|
||||
$payload = $this->buildOverview($user, $month, $year, $tree);
|
||||
|
||||
return BackofficeStatisticsSnapshot::query()->updateOrCreate(
|
||||
[
|
||||
'user_id' => $user->id,
|
||||
'year' => $year,
|
||||
'month' => $month,
|
||||
'tree' => $tree,
|
||||
],
|
||||
[
|
||||
'payload' => $payload,
|
||||
|
|
@ -152,9 +242,9 @@ class BackofficeDashboardService
|
|||
/**
|
||||
* @return array{month: int, year: int, metric_labels: array<string, string>, lines: array<int, array<string, mixed>>, totals: array<string, mixed>}
|
||||
*/
|
||||
private function buildOverview(User $user, int $month, int $year): array
|
||||
private function buildOverview(User $user, int $month, int $year, string $tree): array
|
||||
{
|
||||
$lineBuckets = $this->lineBuckets($user->id);
|
||||
$lineBuckets = $this->lineBucketsFor((int) $user->id, $tree, $month, $year);
|
||||
$recruiter = $this->recruiterRanking($user, $lineBuckets, $month, $year);
|
||||
$lines = [];
|
||||
$totals = $this->emptyLine(0, []);
|
||||
|
|
@ -179,10 +269,13 @@ class BackofficeDashboardService
|
|||
// Aeltere Snapshots bleiben lesbar, ihre Detaillisten fallen auf die
|
||||
// Monatsend-Rueckrechnung zurueck ({@see BackofficeDrilldownService}).
|
||||
'payload_version' => self::PAYLOAD_VERSION,
|
||||
'tree' => $tree,
|
||||
'tree_source' => ($tree === self::TREE_COMMISSION && $this->storedStructureFor($month, $year) !== null) ? 'stored_structure' : 'live',
|
||||
'metric_labels' => $this->metricLabels(),
|
||||
'lines' => $lines,
|
||||
'totals' => $totals,
|
||||
'non_payline_points' => $this->nonPaylinePointsSum($totals['user_ids'], $month, $year),
|
||||
'storno_floor_points' => $totals['storno_floor_points'],
|
||||
'new_customer_abos' => $this->newCustomerAboRanking($user, $lineBuckets, $month, $year),
|
||||
'leader_abos' => $this->leaderAboOverview($lineBuckets),
|
||||
'top_customer_abos' => $this->topCustomerAboRanking($user, $lineBuckets, $month, $year),
|
||||
|
|
@ -197,12 +290,14 @@ class BackofficeDashboardService
|
|||
* @param array<string, mixed> $overview
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function withMeta(array $overview, string $source, ?string $calculatedAt = null): array
|
||||
private function withMeta(array $overview, string $source, string $tree, ?string $calculatedAt = null): array
|
||||
{
|
||||
$overview['_meta'] = [
|
||||
'source' => $source,
|
||||
'source_label' => $source === 'snapshot' ? 'Snapshot' : 'Live',
|
||||
'calculated_at' => $calculatedAt,
|
||||
'tree' => $tree,
|
||||
'tree_label' => self::treeLabels()[$tree] ?? $tree,
|
||||
];
|
||||
|
||||
return $overview;
|
||||
|
|
@ -248,19 +343,212 @@ class BackofficeDashboardService
|
|||
}
|
||||
|
||||
/**
|
||||
* @return int[]
|
||||
* Die Linien des Provisionsbaums: {@see lineBuckets()} gefiltert und gekappt
|
||||
* nach denselben Regeln, mit denen der TreeCalcBot seine Struktur laedt
|
||||
* ({@see BusinessUserItemOptimized::readParentsBusinessUsers()}). Ein Knoten,
|
||||
* der die Bedingungen nicht erfuellt, faellt samt seinem GESAMTEN Unterbaum
|
||||
* heraus — genau wie in der Provisionsberechnung, wo unter einem
|
||||
* ausgefilterten Sponsor nicht weitergelesen wird.
|
||||
*
|
||||
* Die BFS-Reihenfolge von lineBuckets() garantiert, dass der Sponsor immer
|
||||
* schon entschieden ist, wenn sein Teampartner drankommt.
|
||||
*
|
||||
* @return array<int, Collection<int, User>>
|
||||
*/
|
||||
public function lineUserIds(int $rootUserId, int $line): array
|
||||
public function commissionLineBuckets(int $rootUserId, int $month, int $year): array
|
||||
{
|
||||
if ($line === 0) {
|
||||
return $this->teamUserIds($rootUserId);
|
||||
$memoKey = $rootUserId.':'.$year.'-'.$month;
|
||||
|
||||
if (isset($this->commissionLineBucketsMemo[$memoKey])) {
|
||||
return $this->commissionLineBucketsMemo[$memoKey];
|
||||
}
|
||||
|
||||
if ($line < 1) {
|
||||
// Abgeschlossener Monat mit gespeichertem Provisionslauf: die damals
|
||||
// verprovisionierte Struktur ist massgeblich, nicht der heutige Baum. Sonst
|
||||
// laufen Statistik und Struktur wieder auseinander, sobald sich seit dem
|
||||
// Monatsabschluss ein Sponsor geaendert hat oder ein Berater reaktiviert wurde.
|
||||
$storedStructure = $this->storedStructureFor($month, $year);
|
||||
|
||||
if ($storedStructure !== null) {
|
||||
return $this->commissionLineBucketsMemo[$memoKey] = $this->storedCommissionLineBuckets($rootUserId, $storedStructure);
|
||||
}
|
||||
|
||||
$endOfMonth = Carbon::create($year, $month, 1)->endOfMonth();
|
||||
$buckets = [];
|
||||
$pruned = [];
|
||||
|
||||
foreach ($this->lineBuckets($rootUserId) as $line => $users) {
|
||||
if ($line > self::COMMISSION_TREE_MAX_LINES) {
|
||||
break;
|
||||
}
|
||||
|
||||
$kept = $users->filter(function (User $member) use (&$pruned, $endOfMonth): bool {
|
||||
if (isset($pruned[(int) $member->m_sponsor]) || ! self::countsInCommissionTree($member, $endOfMonth)) {
|
||||
$pruned[(int) $member->id] = true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})->values();
|
||||
|
||||
if ($kept->isEmpty()) {
|
||||
// Ohne Mitglieder auf dieser Linie kann es auch darunter keine geben.
|
||||
break;
|
||||
}
|
||||
|
||||
$buckets[$line] = $kept;
|
||||
}
|
||||
|
||||
return $this->commissionLineBucketsMemo[$memoKey] = $buckets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Der abgeschlossene Provisionslauf eines Monats — nur fuer abgeschlossene
|
||||
* Monate: im laufenden Monat rechnet die Statistik live, auch wenn der
|
||||
* Nachtlauf schon eine Struktur gespeichert hat (Buchungen des Tages sollen
|
||||
* sofort sichtbar sein; die Struktur-Seite holt sie am Folgetag nach).
|
||||
*/
|
||||
public function storedStructureFor(int $month, int $year): ?UserBusinessStructure
|
||||
{
|
||||
if (! $this->isClosedMonth($month, $year)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return UserBusinessStructure::query()
|
||||
->where('year', $year)
|
||||
->where('month', $month)
|
||||
->where('completed', true)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Linien aus der gespeicherten Struktur: `user_businesses` traegt je Mitglied
|
||||
* des Provisionsbaums den Sponsor zum Zeitpunkt des Laufs; wer damals nicht im
|
||||
* Baum stand, hat keine Zeile. Ein BFS ueber diese Zeilen ergibt exakt die
|
||||
* Struktur, mit der der Monat verprovisioniert wurde.
|
||||
*
|
||||
* @return array<int, Collection<int, User>>
|
||||
*/
|
||||
private function storedCommissionLineBuckets(int $rootUserId, UserBusinessStructure $structure): array
|
||||
{
|
||||
$childrenBySponsor = [];
|
||||
|
||||
UserBusiness::query()
|
||||
->where('month', $structure->month)
|
||||
->where('year', $structure->year)
|
||||
->where('b_structure_id', $structure->id)
|
||||
->orderBy('user_id')
|
||||
->get(['user_id', 'sponsor'])
|
||||
->each(function (UserBusiness $row) use (&$childrenBySponsor): void {
|
||||
$sponsorId = (int) ($row->sponsor->user_id ?? 0);
|
||||
|
||||
if ($sponsorId > 0 && $sponsorId !== (int) $row->user_id) {
|
||||
$childrenBySponsor[$sponsorId][] = (int) $row->user_id;
|
||||
}
|
||||
});
|
||||
|
||||
$idsByLine = [];
|
||||
$visited = [$rootUserId => true];
|
||||
$currentIds = [$rootUserId];
|
||||
|
||||
for ($line = 1; $line <= self::COMMISSION_TREE_MAX_LINES && $currentIds !== []; $line++) {
|
||||
$nextIds = [];
|
||||
|
||||
foreach ($currentIds as $parentId) {
|
||||
foreach ($childrenBySponsor[$parentId] ?? [] as $childId) {
|
||||
if (isset($visited[$childId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$visited[$childId] = true;
|
||||
$nextIds[] = $childId;
|
||||
}
|
||||
}
|
||||
|
||||
if ($nextIds === []) {
|
||||
break;
|
||||
}
|
||||
|
||||
$idsByLine[$line] = $nextIds;
|
||||
$currentIds = $nextIds;
|
||||
}
|
||||
|
||||
if ($idsByLine === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return ($this->lineBuckets($rootUserId)[$line] ?? collect())->pluck('id')->map(fn ($id) => (int) $id)->all();
|
||||
$users = User::query()
|
||||
->with(['account', 'user_level'])
|
||||
->whereIn('id', array_merge(...array_values($idsByLine)))
|
||||
->whereNull('deleted_at')
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$buckets = [];
|
||||
|
||||
foreach ($idsByLine as $line => $ids) {
|
||||
$buckets[$line] = collect($ids)
|
||||
->map(fn (int $id) => $users->get($id))
|
||||
->filter()
|
||||
->values();
|
||||
}
|
||||
|
||||
return $buckets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dieselben Bedingungen, mit denen der TreeCalcBot seine Struktur laedt
|
||||
* ({@see BusinessUserItemOptimized::readParentsBusinessUsers()}): kein
|
||||
* System-/Adminkonto, Mitgliedsebene und Abrechnungskonto gesetzt (das
|
||||
* Ablaufdatum spielt dort KEINE Rolle), bis zum Monatsende aktiv geworden.
|
||||
* Geloeschte User sind schon in lineBuckets() ausgeschlossen.
|
||||
*/
|
||||
public static 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Collection<int, User>>
|
||||
*/
|
||||
public function lineBucketsFor(int $rootUserId, string $tree, int $month, int $year): array
|
||||
{
|
||||
return self::normalizeTree($tree) === self::TREE_DOWNLINE
|
||||
? $this->lineBuckets($rootUserId)
|
||||
: $this->commissionLineBuckets($rootUserId, $month, $year);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mitglieder einer Linie in der gewaehlten Baum-Definition; Linie 0 = das ganze Team.
|
||||
*
|
||||
* @return int[]
|
||||
*/
|
||||
public function lineUserIds(int $rootUserId, int $line, int $month, int $year, string $tree = self::TREE_COMMISSION): array
|
||||
{
|
||||
if ($line < 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$buckets = $this->lineBucketsFor($rootUserId, $tree, $month, $year);
|
||||
|
||||
if ($line === 0) {
|
||||
return collect($buckets)
|
||||
->flatMap(fn (Collection $users) => $users->pluck('id'))
|
||||
->map(fn ($id) => (int) $id)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
return ($buckets[$line] ?? collect())->pluck('id')->map(fn ($id) => (int) $id)->all();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -346,6 +634,7 @@ class BackofficeDashboardService
|
|||
'customer_single_order_points' => $salesSummary['customer_single_order_points'],
|
||||
'customer_other_points' => $salesSummary['customer_other_points'],
|
||||
'total_points' => $salesSummary['total_points'],
|
||||
'storno_floor_points' => $salesSummary['storno_floor_points'],
|
||||
'turnover_net' => $salesSummary['turnover_net'],
|
||||
'shop_1000' => $this->shop1000Count($userIds, $month, $year),
|
||||
];
|
||||
|
|
@ -372,6 +661,7 @@ class BackofficeDashboardService
|
|||
'customer_single_order_points' => 0.0,
|
||||
'customer_other_points' => 0.0,
|
||||
'total_points' => 0.0,
|
||||
'storno_floor_points' => 0.0,
|
||||
'turnover_net' => 0.0,
|
||||
'shop_1000' => 0,
|
||||
];
|
||||
|
|
@ -389,6 +679,7 @@ class BackofficeDashboardService
|
|||
}
|
||||
|
||||
$totals['turnover_net'] += $row['turnover_net'];
|
||||
$totals['storno_floor_points'] += $row['storno_floor_points'] ?? 0.0;
|
||||
$totals['team_partner_abos_new'] += $row['team_partner_abos_new'];
|
||||
$totals['team_customer_abos_new'] += $row['team_customer_abos_new'];
|
||||
$totals['user_ids'] = array_merge($totals['user_ids'], $row['user_ids']);
|
||||
|
|
@ -460,7 +751,12 @@ class BackofficeDashboardService
|
|||
|
||||
/**
|
||||
* @param int[] $userIds
|
||||
* @return array{own_points: float, external_points: float, customer_abo_points: float, customer_single_order_points: float, customer_other_points: float, total_points: float, turnover_net: float}
|
||||
* Die Kennzahl `total_points` („Umsatz (Punkte)") sind die Provisionspunkte mit
|
||||
* der Knotenregel des Provisionsbaums ({@see SalesVolumePoints::floorPerUser()}) —
|
||||
* dieselbe Zahl wie die Linien-Punkte der Team-Struktur. Die Aufteilung
|
||||
* (Eigen-/Kundenpunkte) bleibt die reine Zeilensumme; `storno_floor_points`
|
||||
* weist aus, was die Knotenregel nicht abgezogen hat.
|
||||
* @return array{own_points: float, external_points: float, customer_abo_points: float, customer_single_order_points: float, customer_other_points: float, total_points: float, storno_floor_points: float, turnover_net: float}
|
||||
*/
|
||||
private function salesSummary(array $userIds, int $month, int $year): array
|
||||
{
|
||||
|
|
@ -477,24 +773,25 @@ class BackofficeDashboardService
|
|||
->selectRaw(SalesVolumePoints::turnoverNet().' as turnover_net')
|
||||
->first();
|
||||
|
||||
$ownPoints = (float) ($summary->own_points ?? 0);
|
||||
$externalPoints = (float) ($summary->external_points ?? 0);
|
||||
$commission = SalesVolumePoints::floorPerUser(SalesVolumePoints::commissionPointsByUser($userIds, $month, $year));
|
||||
|
||||
return [
|
||||
'own_points' => $ownPoints,
|
||||
'external_points' => $externalPoints,
|
||||
'own_points' => (float) ($summary->own_points ?? 0),
|
||||
'external_points' => (float) ($summary->external_points ?? 0),
|
||||
'customer_abo_points' => (float) ($summary->customer_abo_points ?? 0),
|
||||
'customer_single_order_points' => (float) ($summary->customer_single_order_points ?? 0),
|
||||
'customer_other_points' => (float) ($summary->customer_other_points ?? 0),
|
||||
'total_points' => $ownPoints + $externalPoints,
|
||||
'total_points' => $commission['points'],
|
||||
'storno_floor_points' => $commission['storno_floor'],
|
||||
'turnover_net' => (float) ($summary->turnover_net ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Summe der Punkte des Teams, die nicht in die Payline fließen (nur-KP-Einträge
|
||||
* wie Neupartner-Einschreibungspunkte) — erklärt die Differenz zwischen den
|
||||
* Gesamtpunkten hier und der PP-Summe der Struktur-/Provisionsansicht.
|
||||
* wie Neupartner-Einschreibungspunkte). Seit die Kennzahl „Umsatz (Punkte)" die
|
||||
* Provisionspunkte zeigt, sind sie dort NICHT mehr enthalten — die Zahl erklaert
|
||||
* die Differenz zu den Eigenpunkten der Aufteilung.
|
||||
*
|
||||
* @param int[] $userIds
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -17,8 +17,9 @@ class BackofficeDrilldownService
|
|||
/**
|
||||
* @return array{metric: string, metric_label: string, only_new: bool, line: int, line_label: string, month: int, year: int, rows: array<int, array<string, mixed>>, summary: array<string, mixed>}
|
||||
*/
|
||||
public function details(User $viewer, int $line, string $metric, int $month, int $year, bool $onlyNew = false): array
|
||||
public function details(User $viewer, int $line, string $metric, int $month, int $year, bool $onlyNew = false, string $tree = BackofficeDashboardService::TREE_COMMISSION): array
|
||||
{
|
||||
$tree = BackofficeDashboardService::normalizeTree($tree);
|
||||
$metricLabels = $this->dashboardService->metricLabels();
|
||||
|
||||
if (! array_key_exists($metric, $metricLabels)) {
|
||||
|
|
@ -32,11 +33,11 @@ class BackofficeDrilldownService
|
|||
// Ohne das rechnet die Liste mit der heutigen Struktur — ein spaeter
|
||||
// hinzugekommener oder verschobener Teampartner taucht dann in einem
|
||||
// laengst abgerechneten Monat auf (oder verschwindet daraus).
|
||||
$frozen = $this->frozenLines($viewer, $month, $year);
|
||||
$frozen = $this->frozenLines($viewer, $month, $year, $tree);
|
||||
|
||||
$userIds = $frozen !== null
|
||||
? $this->frozenUserIds($frozen, $line)
|
||||
: $this->dashboardService->lineUserIds($viewer->id, $line);
|
||||
: $this->dashboardService->lineUserIds($viewer->id, $line, $month, $year, $tree);
|
||||
|
||||
if ($metric === 'shop_1000' && $line === 0) {
|
||||
$userIds[] = (int) $viewer->id;
|
||||
|
|
@ -67,6 +68,8 @@ class BackofficeDrilldownService
|
|||
'line_label' => $line > 0 ? 'Linie '.$line : 'Alle Linien',
|
||||
'month' => $month,
|
||||
'year' => $year,
|
||||
'tree' => $tree,
|
||||
'tree_label' => BackofficeDashboardService::treeLabels()[$tree] ?? $tree,
|
||||
'rows' => $rows,
|
||||
'summary' => $this->summary($rows),
|
||||
];
|
||||
|
|
@ -107,6 +110,7 @@ class BackofficeDrilldownService
|
|||
'customer_single_order_points' => (float) collect($rows)->sum('customer_single_order_points'),
|
||||
'customer_other_points' => (float) collect($rows)->sum('customer_other_points'),
|
||||
'total_points' => (float) collect($rows)->sum('total_points'),
|
||||
'storno_floor_points' => (float) collect($rows)->sum('storno_floor_points'),
|
||||
'deliveries' => (int) collect($rows)->sum('deliveries'),
|
||||
];
|
||||
}
|
||||
|
|
@ -222,12 +226,13 @@ class BackofficeDrilldownService
|
|||
*
|
||||
* @return array<int, array<string, mixed>>|null
|
||||
*/
|
||||
private function frozenLines(User $viewer, int $month, int $year): ?array
|
||||
private function frozenLines(User $viewer, int $month, int $year, string $tree): ?array
|
||||
{
|
||||
$snapshot = BackofficeStatisticsSnapshot::query()
|
||||
->where('user_id', $viewer->id)
|
||||
->where('month', $month)
|
||||
->where('year', $year)
|
||||
->where('tree', $tree)
|
||||
->first();
|
||||
|
||||
if (! $snapshot) {
|
||||
|
|
@ -394,12 +399,14 @@ class BackofficeDrilldownService
|
|||
->selectRaw(SalesVolumePoints::customerSingleOrderPoints().' as customer_single_order_points')
|
||||
->selectRaw(SalesVolumePoints::customerOtherPoints().' as customer_other_points')
|
||||
->selectRaw(SalesVolumePoints::totalPoints().' as total_points')
|
||||
->selectRaw(SalesVolumePoints::commissionPoints().' as commission_points')
|
||||
->groupBy('user_sales_volumes.user_id');
|
||||
|
||||
// `<> 0` statt `> 0`: ein Berater mit negativem Monatssaldo (Storno) steckt in
|
||||
// der Uebersichtssumme und muss deshalb auch in der Detailliste auftauchen —
|
||||
// sonst ergibt die Detailsumme etwas anderes als die verlinkte Kachel.
|
||||
// Nur die echte Qualifikationsliste behaelt ihre Schwelle.
|
||||
// der Uebersichtssumme (dort mit 0 gewertet, siehe Knotenregel) und muss
|
||||
// deshalb auch in der Detailliste auftauchen — sonst ergibt die Detailsumme
|
||||
// etwas anderes als die verlinkte Kachel. Nur die echte Qualifikationsliste
|
||||
// behaelt ihre Schwelle.
|
||||
if ($metric === 'own_points') {
|
||||
$rows->havingRaw(SalesVolumePoints::ownPoints().' <> 0');
|
||||
}
|
||||
|
|
@ -421,14 +428,17 @@ class BackofficeDrilldownService
|
|||
}
|
||||
|
||||
if ($metric === 'total_points') {
|
||||
$rows->havingRaw(SalesVolumePoints::totalPoints().' <> 0');
|
||||
$rows->havingRaw(SalesVolumePoints::commissionPoints().' <> 0');
|
||||
}
|
||||
|
||||
if ($metric === 'shop_1000') {
|
||||
$rows->havingRaw(SalesVolumePoints::totalPoints().' >= 1000');
|
||||
}
|
||||
|
||||
$salesRows = $rows->orderByDesc('total_points')->get();
|
||||
// „Gesamtpunkte" der Liste = Provisionspunkte mit Knotenregel (negativer Saldo
|
||||
// = 0), damit die Listensumme der Kachel „Umsatz (Punkte)" entspricht. Die
|
||||
// 1000-Punkte-Liste behaelt ihre eigene Definition (Eigen- + Kundenpunkte).
|
||||
$salesRows = $rows->orderByDesc($metric === 'shop_1000' ? 'total_points' : 'commission_points')->get();
|
||||
$users = User::query()->with(['account', 'user_level'])->whereIn('id', $salesRows->pluck('user_id'))->get()->keyBy('id');
|
||||
|
||||
return $salesRows
|
||||
|
|
@ -443,7 +453,8 @@ class BackofficeDrilldownService
|
|||
'customer_abo_points' => (float) $row->customer_abo_points,
|
||||
'customer_single_order_points' => (float) $row->customer_single_order_points,
|
||||
'customer_other_points' => (float) $row->customer_other_points,
|
||||
'total_points' => (float) $row->total_points,
|
||||
'total_points' => $metric === 'shop_1000' ? (float) $row->total_points : max(0.0, (float) $row->commission_points),
|
||||
'storno_floor_points' => $metric === 'shop_1000' ? 0.0 : max(0.0, -(float) $row->commission_points),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ namespace App\Services\Backoffice;
|
|||
|
||||
use App\Models\UserSalesVolume;
|
||||
use App\Services\BusinessPlan\SalesPointsVolume;
|
||||
use App\Services\BusinessPlan\TreeCalcBotOptimized;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
|
|
@ -48,6 +49,19 @@ class SalesVolumePoints
|
|||
|
||||
private const TURNOVER_PREDICATE = 'user_sales_volumes.status IN (1, 2, 4, 5, 6)';
|
||||
|
||||
/**
|
||||
* Provisionspunkte (TP + Shop) je Zeile — dieselbe Abgrenzung, mit der
|
||||
* {@see SalesPointsVolume::reCalculateSalesPointsVolume()} die Laufstaende
|
||||
* `month_TP_points` und `month_shop_points` fuehrt, aus denen der TreeCalcBot
|
||||
* die Team-Punkte der Struktur-/Provisionsansicht bildet
|
||||
* ({@see UserSalesVolume::getPointsTPSum()}):
|
||||
* - Eigenbuchungen zaehlen nur, wenn sie NICHT als reine Karrierepunkte
|
||||
* gekennzeichnet sind (`status_points = 2` — Neupartner-Einschreibung,
|
||||
* manuelle Gutschriften); NULL gilt wie 1 (PHP-Vergleich `=== 2`).
|
||||
* - Shop-/Kundenbuchungen zaehlen immer.
|
||||
*/
|
||||
private const COMMISSION_PREDICATE = '(('.self::OWN_PREDICATE.' AND COALESCE(user_sales_volumes.status_points, 1) <> 2) OR '.self::SHOP_PREDICATE.')';
|
||||
|
||||
/**
|
||||
* Die zaehlbaren Buchungsstatus als Liste — fuer Auswertungen, die nicht ueber
|
||||
* die SQL-Ausdruecke hier gehen, aber dieselbe Abgrenzung brauchen. Status 0
|
||||
|
|
@ -145,6 +159,69 @@ class SalesVolumePoints
|
|||
return self::sumWhere(self::OWN_PREDICATE.' AND user_sales_volumes.status_points = 2', 'user_sales_volumes.points');
|
||||
}
|
||||
|
||||
/**
|
||||
* Provisionspunkte als SUM-Ausdruck (ungefloort — der Provisionsbaum wertet
|
||||
* einen negativen Monatssaldo je Berater mit 0, siehe
|
||||
* {@see commissionPointsByUser()}).
|
||||
*/
|
||||
public static function commissionPoints(): string
|
||||
{
|
||||
return self::sumWhere(self::COMMISSION_PREDICATE, 'user_sales_volumes.points');
|
||||
}
|
||||
|
||||
/**
|
||||
* Provisionspunkte je User im Monat — Grundlage der Kennzahl „Umsatz (Punkte)"
|
||||
* der Backoffice-Statistik, damit sie mit der Team-Struktur uebereinstimmt.
|
||||
*
|
||||
* Bewusst je User statt als Gesamtsumme: der TreeCalcBot addiert je Knoten
|
||||
* nur Werte > 0 ({@see TreeCalcBotOptimized}),
|
||||
* ein negativer Monatssaldo (Storno ueber die Buchungen des Monats hinaus)
|
||||
* zaehlt dort also 0 und nicht negativ. Der Aufrufer floort deshalb je User.
|
||||
*
|
||||
* @param int[] $userIds
|
||||
* @return Collection<int, float> user_id => Punkte (ungefloort)
|
||||
*/
|
||||
public static function commissionPointsByUser(array $userIds, int $month, int $year): Collection
|
||||
{
|
||||
if ($userIds === []) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return UserSalesVolume::query()
|
||||
->whereIn('user_id', $userIds)
|
||||
->where('month', $month)
|
||||
->where('year', $year)
|
||||
->groupBy('user_id')
|
||||
->select('user_id')
|
||||
->selectRaw('('.self::commissionPoints().') as commission_points')
|
||||
->pluck('commission_points', 'user_id')
|
||||
->map(fn ($points) => (float) $points);
|
||||
}
|
||||
|
||||
/**
|
||||
* Summe der Provisionspunkte mit der Knotenregel des Provisionsbaums (negativer
|
||||
* Saldo je User = 0) plus den dabei nicht abgezogenen Negativsaldo, damit die
|
||||
* Statistik ausweisen kann, warum die Summe nicht der reinen Zeilensumme entspricht.
|
||||
*
|
||||
* @param Collection<int, float> $pointsByUser
|
||||
* @return array{points: float, storno_floor: float}
|
||||
*/
|
||||
public static function floorPerUser(Collection $pointsByUser): array
|
||||
{
|
||||
$points = 0.0;
|
||||
$stornoFloor = 0.0;
|
||||
|
||||
foreach ($pointsByUser as $userPoints) {
|
||||
if ($userPoints > 0) {
|
||||
$points += $userPoints;
|
||||
} else {
|
||||
$stornoFloor += -$userPoints;
|
||||
}
|
||||
}
|
||||
|
||||
return ['points' => round($points, 2), 'storno_floor' => round($stornoFloor, 2)];
|
||||
}
|
||||
|
||||
public static function turnoverNet(): string
|
||||
{
|
||||
return self::sumWhere(self::TURNOVER_PREDICATE, 'user_sales_volumes.total_net');
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use App\Models\UserSalesVolume;
|
|||
use App\Services\Incentive\IncentiveTracker;
|
||||
use App\Services\Util;
|
||||
use App\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use stdClass;
|
||||
|
||||
class SalesPointsVolume
|
||||
|
|
@ -74,10 +75,32 @@ class SalesPointsVolume
|
|||
return $month_points;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Zeilen eines Beraters im Monat in Buchungsreihenfolge — SPERREND
|
||||
* gelesen (SELECT ... FOR UPDATE). Ein sperrendes Lesen liefert immer den
|
||||
* zuletzt committeten Stand, nicht den Transaktions-Snapshot: Laufen zwei
|
||||
* Zahlungs-Webhooks desselben Beraters parallel (Abo-Ausfuehrungen), wartet
|
||||
* der zweite auf den ersten und sieht dessen Zeile. Ohne die Sperre trug
|
||||
* die spaetere Zeile einen Laufstand ohne die fruehere Buchung, und die
|
||||
* Provision rechnete zu niedrig (Ticket C2). Ausserhalb einer Transaktion
|
||||
* ist die Sperre wirkungslos, aber unschaedlich.
|
||||
*
|
||||
* @return Builder<UserSalesVolume>
|
||||
*/
|
||||
public static function runningTotalRows($user_id, $month, $year)
|
||||
{
|
||||
return UserSalesVolume::query()
|
||||
->where('user_id', $user_id)
|
||||
->where('month', $month)
|
||||
->where('year', $year)
|
||||
->orderBy('id', 'ASC')
|
||||
->lockForUpdate();
|
||||
}
|
||||
|
||||
public static function reCalculateSalesPointsVolume($user_id, $month, $year)
|
||||
{
|
||||
|
||||
$userSalesVolumes = UserSalesVolume::where('user_id', $user_id)->where('month', $month)->where('year', $year)->orderBy('id', 'ASC')->get();
|
||||
$userSalesVolumes = self::runningTotalRows($user_id, $month, $year)->get();
|
||||
$month_points = new stdClass;
|
||||
$month_points->KP = 0;
|
||||
$month_points->TP = 0;
|
||||
|
|
|
|||
141
app/Services/BusinessPlan/SalesVolumeRunningTotalService.php
Normal file
141
app/Services/BusinessPlan/SalesVolumeRunningTotalService.php
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\BusinessPlan;
|
||||
|
||||
use App\Models\UserBusinessStructure;
|
||||
use App\Models\UserSalesVolume;
|
||||
use App\Services\Backoffice\SalesVolumePoints;
|
||||
use Carbon\Carbon;
|
||||
|
||||
/**
|
||||
* Laufstand-Drift in `user_sales_volumes` (Ticket C2, Entwicklungsplan
|
||||
* September 2026).
|
||||
*
|
||||
* Jede Buchung traegt neben ihren eigenen Punkten (`points`) den kumulierten
|
||||
* Monatsstand des Beraters (`month_TP_points`, `month_shop_points`), den
|
||||
* {@see SalesPointsVolume::reCalculateSalesPointsVolume()} nach jedem Insert
|
||||
* ueber alle Zeilen neu schreibt. Der TreeCalcBot liest nur den Laufstand der
|
||||
* LETZTEN Zeile. Laufen zwei Zahlungs-Webhooks desselben Beraters parallel
|
||||
* (Abo-Ausfuehrungen um 04:00), sah die spaetere Transaktion unter
|
||||
* REPEATABLE READ die Zeile der frueheren nicht — ihre letzte Zeile traegt
|
||||
* einen zu niedrigen Laufstand, und die Provision rechnet zu niedrig, obwohl
|
||||
* alle Einzelbuchungen vollstaendig vorhanden sind.
|
||||
*
|
||||
* Dieser Service vergleicht je User die Zeilensumme der Provisionspunkte
|
||||
* ({@see SalesVolumePoints::commissionPointsByUser()}, Basis der Statistik) mit
|
||||
* dem Laufstand der letzten Zeile (Basis von Struktur und Provision) und rechnet
|
||||
* abweichende User mit der Anwendungsfunktion neu. Die eigentliche Ursache
|
||||
* schliesst das Locking in der Neuberechnung; dieser Lauf ist das
|
||||
* Sicherheitsnetz vor dem Provisionslauf.
|
||||
*/
|
||||
class SalesVolumeRunningTotalService
|
||||
{
|
||||
/**
|
||||
* User, deren letzte Zeile einen anderen Laufstand traegt als die
|
||||
* Zeilensumme ergibt.
|
||||
*
|
||||
* @param int[]|null $userIds Einschraenkung auf diese User, sonst alle mit Buchungen im Monat
|
||||
* @return array<int, array{rows: float, running: float}> user_id => Zeilensumme / Laufstand
|
||||
*/
|
||||
public function detectDrift(int $month, int $year, ?array $userIds = null): array
|
||||
{
|
||||
$userIds ??= UserSalesVolume::query()
|
||||
->where('month', $month)
|
||||
->where('year', $year)
|
||||
->whereNotNull('user_id')
|
||||
->distinct()
|
||||
->pluck('user_id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->all();
|
||||
|
||||
if ($userIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rowSums = SalesVolumePoints::commissionPointsByUser($userIds, $month, $year);
|
||||
|
||||
$lastRows = UserSalesVolume::query()
|
||||
->whereIn('user_id', $userIds)
|
||||
->where('month', $month)
|
||||
->where('year', $year)
|
||||
->whereIn('id', function ($sub) use ($month, $year) {
|
||||
$sub->selectRaw('MAX(id)')
|
||||
->from('user_sales_volumes')
|
||||
->where('month', $month)
|
||||
->where('year', $year)
|
||||
->groupBy('user_id');
|
||||
})
|
||||
->get(['user_id', 'month_TP_points', 'month_shop_points'])
|
||||
->keyBy('user_id');
|
||||
|
||||
$drift = [];
|
||||
|
||||
foreach ($rowSums as $userId => $rowSum) {
|
||||
$last = $lastRows->get($userId);
|
||||
$runningTotal = $last ? (float) $last->month_TP_points + (float) $last->month_shop_points : 0.0;
|
||||
|
||||
if (abs($runningTotal - (float) $rowSum) > 0.005) {
|
||||
$drift[(int) $userId] = ['rows' => (float) $rowSum, 'running' => $runningTotal];
|
||||
}
|
||||
}
|
||||
|
||||
ksort($drift);
|
||||
|
||||
return $drift;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rechnet die Laufstaende der abweichenden User neu.
|
||||
*
|
||||
* @param int[]|null $userIds
|
||||
* @return array<int, array{rows: float, running: float}> die reparierten User (Stand vor der Reparatur)
|
||||
*/
|
||||
public function repair(int $month, int $year, ?array $userIds = null): array
|
||||
{
|
||||
$drift = $this->detectDrift($month, $year, $userIds);
|
||||
|
||||
foreach (array_keys($drift) as $userId) {
|
||||
SalesPointsVolume::reCalculateSalesPointsVolume($userId, $month, $year);
|
||||
}
|
||||
|
||||
return $drift;
|
||||
}
|
||||
|
||||
/**
|
||||
* Monate, die der naechtliche Sicherheitsnetz-Lauf pruefen darf: der laufende
|
||||
* Monat immer, der Vormonat nur, solange sein Provisionslauf noch nicht
|
||||
* gelaufen ist (Monatserster vor 03:00 bzw. spaeterer Ausfuehrungstag). Ein
|
||||
* abgeschlossener Monat wird nicht angefasst — seine Laufstaende sind so
|
||||
* abgerechnet worden, und eine Korrektur waere eine Nachberechnung.
|
||||
*
|
||||
* @return array<int, array{month: int, year: int}>
|
||||
*/
|
||||
public function monthsToGuard(?Carbon $now = null): array
|
||||
{
|
||||
$now ??= now();
|
||||
$months = [];
|
||||
|
||||
$previous = $now->copy()->subMonthNoOverflow();
|
||||
|
||||
if (! self::isMonthClosed((int) $previous->month, (int) $previous->year)) {
|
||||
$months[] = ['month' => (int) $previous->month, 'year' => (int) $previous->year];
|
||||
}
|
||||
|
||||
$months[] = ['month' => (int) $now->month, 'year' => (int) $now->year];
|
||||
|
||||
return $months;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abgeschlossen ist ein Monat, sobald der Provisionslauf seine Struktur
|
||||
* gespeichert hat.
|
||||
*/
|
||||
public static function isMonthClosed(int $month, int $year): bool
|
||||
{
|
||||
return UserBusinessStructure::query()
|
||||
->where('month', $month)
|
||||
->where('year', $year)
|
||||
->where('completed', true)
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
23
app/Services/Database/ConcurrencyError.php
Normal file
23
app/Services/Database/ConcurrencyError.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Database;
|
||||
|
||||
use Illuminate\Database\DetectsConcurrencyErrors;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Erkennt Deadlocks und Lock-Wait-Timeouts der Datenbank — dieselbe Pruefung,
|
||||
* mit der `DB::transaction($callback, $attempts)` einen Wiederholungsversuch
|
||||
* ausloest. Nach einem Deadlock hat InnoDB die gesamte Transaktion bereits
|
||||
* zurueckgerollt; ein solcher Fehler darf deshalb nirgends stillschweigend
|
||||
* abgefangen werden, sondern muss bis zum Transaktions-Wrapper durchschlagen.
|
||||
*/
|
||||
class ConcurrencyError
|
||||
{
|
||||
use DetectsConcurrencyErrors;
|
||||
|
||||
public static function isCausedBy(Throwable $e): bool
|
||||
{
|
||||
return (new self)->causedByConcurrencyError($e);
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ use App\Models\UserCreditItem;
|
|||
use App\Models\UserLevel;
|
||||
use App\Repositories\InvoiceRepository;
|
||||
use App\Services\BusinessPlan\SalesPointsVolume;
|
||||
use App\Services\Database\ConcurrencyError;
|
||||
use App\Services\Incentive\IncentiveTracker;
|
||||
use App\Services\Payment\Stripe\StripeInvoiceReferenceService;
|
||||
use App\User;
|
||||
|
|
@ -323,6 +324,14 @@ class Payment
|
|||
$invoice_repo = new InvoiceRepository($shopping_order);
|
||||
$invoice_repo->createAndSalesVolume();
|
||||
} catch (\Throwable $e) {
|
||||
// Deadlock / Lock-Timeout: InnoDB hat die Transaktion bereits
|
||||
// zurueckgerollt — hier weiterzumachen wuerde die Bestellung als
|
||||
// bezahlt bestaetigen, ohne dass etwas gespeichert ist. Der
|
||||
// Webhook-Wrapper wiederholt die Transaktion (Ticket C2).
|
||||
if (ConcurrencyError::isCausedBy($e)) {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
\Log::error('Payment::paymentStatusPaidAction - Rechnungserstellung fehlgeschlagen', [
|
||||
'shopping_order_id' => $shopping_order->id,
|
||||
'error' => $e->getMessage(),
|
||||
|
|
|
|||
|
|
@ -18,6 +18,12 @@ class PayoneWebhookHandler
|
|||
*
|
||||
* @var array<string, int>
|
||||
*/
|
||||
/**
|
||||
* Anlaeufe fuer die Paid-Transaktion — wiederholt nur bei Deadlock /
|
||||
* Lock-Timeout (Ticket C2).
|
||||
*/
|
||||
private const PAID_TRANSACTION_ATTEMPTS = 3;
|
||||
|
||||
private const TXACTION_PRIORITY = [
|
||||
'appointed' => 1,
|
||||
'pending' => 2,
|
||||
|
|
@ -177,23 +183,23 @@ class PayoneWebhookHandler
|
|||
}
|
||||
|
||||
if ($data['txaction'] === 'paid') {
|
||||
// Use DB transaction and row locking to prevent race conditions
|
||||
\DB::beginTransaction();
|
||||
// Transaktion mit Zeilensperre auf der Bestellung gegen doppelte
|
||||
// Verarbeitung; bis zu 3 Anlaeufe, weil die Punkte-Buchung den Berater
|
||||
// sperrt und zwei parallele Webhooks desselben Beraters sich in einem
|
||||
// Deadlock treffen koennen (Ticket C2) — der Verlierer wiederholt.
|
||||
try {
|
||||
// Lock the shopping order row to prevent concurrent processing
|
||||
$locked_order = ShoppingOrder::where('id', $shopping_order->id)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
// Double-check if payment was already processed
|
||||
if (! $locked_order->paid) {
|
||||
$send_link = Payment::paymentStatusPaidAction($locked_order, true, $shopping_payment);
|
||||
\DB::commit();
|
||||
} else {
|
||||
$send_mail = false;
|
||||
\DB::commit();
|
||||
}
|
||||
[$send_link, $send_mail] = \DB::transaction(function () use ($shopping_order, $shopping_payment, $send_link, $send_mail) {
|
||||
$locked_order = ShoppingOrder::where('id', $shopping_order->id)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($locked_order->paid) {
|
||||
return [$send_link, false];
|
||||
}
|
||||
|
||||
return [Payment::paymentStatusPaidAction($locked_order, true, $shopping_payment), $send_mail];
|
||||
}, self::PAID_TRANSACTION_ATTEMPTS);
|
||||
} catch (\Exception $e) {
|
||||
\DB::rollBack();
|
||||
MyLog::writeLog(
|
||||
'payone',
|
||||
'error',
|
||||
|
|
|
|||
|
|
@ -46,6 +46,12 @@ class StripeWebhookHandler
|
|||
*
|
||||
* @var string[]
|
||||
*/
|
||||
/**
|
||||
* Anlaeufe fuer die Paid-Transaktion — wiederholt nur bei Deadlock /
|
||||
* Lock-Timeout (Ticket C2).
|
||||
*/
|
||||
private const PAID_TRANSACTION_ATTEMPTS = 3;
|
||||
|
||||
private const EVENT_TYPES_WITHOUT_PAYMENT = [
|
||||
'checkout.session.expired',
|
||||
];
|
||||
|
|
@ -356,18 +362,20 @@ class StripeWebhookHandler
|
|||
$sendLink = false;
|
||||
$sendMail = true;
|
||||
|
||||
DB::beginTransaction();
|
||||
// Bis zu 3 Anlaeufe: die Punkte-Buchung sperrt den Berater, zwei parallele
|
||||
// Webhooks desselben Beraters koennen sich in einem Deadlock treffen
|
||||
// (Ticket C2) — der Verlierer wiederholt die ganze Transaktion.
|
||||
try {
|
||||
$lockedOrder = ShoppingOrder::where('id', $order->id)->lockForUpdate()->first();
|
||||
if (! $lockedOrder->paid) {
|
||||
$sendLink = Payment::paymentStatusPaidAction($lockedOrder, true, $payment);
|
||||
DB::commit();
|
||||
} else {
|
||||
$sendMail = false;
|
||||
DB::commit();
|
||||
}
|
||||
[$sendLink, $sendMail] = DB::transaction(function () use ($order, $payment, $sendLink, $sendMail) {
|
||||
$lockedOrder = ShoppingOrder::where('id', $order->id)->lockForUpdate()->first();
|
||||
|
||||
if ($lockedOrder->paid) {
|
||||
return [$sendLink, false];
|
||||
}
|
||||
|
||||
return [Payment::paymentStatusPaidAction($lockedOrder, true, $payment), $sendMail];
|
||||
}, self::PAID_TRANSACTION_ATTEMPTS);
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
Log::channel('stripe')->error('Stripe-Paid-Pfad fehlgeschlagen (Stripe stellt erneut zu): '.$e->getMessage(), [
|
||||
'shopping_order_id' => $order->id,
|
||||
'shopping_payment_id' => $payment->id,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Backoffice-Statistik: je Monat ein Snapshot pro Baum-Definition
|
||||
* (Provisionsbaum / gesamte Downline, Kundenentscheid 2026-09-14).
|
||||
*
|
||||
* Bestehende Snapshots wurden mit der kompletten Downline und der alten
|
||||
* Punkte-Definition gebaut — sie werden als `downline` gekennzeichnet. Die
|
||||
* Provisionsbaum-Snapshots der abgeschlossenen Monate legt der naechste Lauf von
|
||||
* `backoffice:store-statistics-snapshots` an (Deploy: einmal mit `--force`, damit
|
||||
* beide Varianten die neue Punkte-Definition tragen).
|
||||
*
|
||||
* Die Werte stehen bewusst als Literale hier (nicht als Konstanten aus
|
||||
* BackofficeDashboardService): eine Migration darf nicht vom Stand des
|
||||
* Anwendungscodes abhaengen — auf dem Server lief sie mit einer noch nicht
|
||||
* neu geladenen Klasse auf „Undefined constant".
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('backoffice_statistics_snapshots', function (Blueprint $table) {
|
||||
$table->string('tree', 16)->default('commission')->after('month');
|
||||
});
|
||||
|
||||
DB::table('backoffice_statistics_snapshots')->update(['tree' => 'downline']);
|
||||
|
||||
// Erst den neuen Unique-Index anlegen, dann den alten entfernen: der
|
||||
// Fremdschluessel auf user_id braucht in MySQL durchgehend einen Index,
|
||||
// der mit user_id beginnt.
|
||||
Schema::table('backoffice_statistics_snapshots', function (Blueprint $table) {
|
||||
$table->unique(['user_id', 'year', 'month', 'tree'], 'backoffice_statistics_snapshot_tree_unique');
|
||||
});
|
||||
|
||||
Schema::table('backoffice_statistics_snapshots', function (Blueprint $table) {
|
||||
$table->dropUnique('backoffice_statistics_snapshot_unique');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::table('backoffice_statistics_snapshots')
|
||||
->where('tree', '<>', 'downline')
|
||||
->delete();
|
||||
|
||||
Schema::table('backoffice_statistics_snapshots', function (Blueprint $table) {
|
||||
$table->unique(['user_id', 'year', 'month'], 'backoffice_statistics_snapshot_unique');
|
||||
});
|
||||
|
||||
Schema::table('backoffice_statistics_snapshots', function (Blueprint $table) {
|
||||
$table->dropUnique('backoffice_statistics_snapshot_tree_unique');
|
||||
$table->dropColumn('tree');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Laufstand-Race (Ticket C2): die Neuberechnung der Laufstaende liest die
|
||||
* Zeilen eines Beraters im Monat jetzt sperrend (SELECT ... FOR UPDATE). Mit
|
||||
* diesem Index sperrt InnoDB genau den Bereich (user_id, year, month) statt
|
||||
* aller Zeilen des Users ueber den Fremdschluessel-Index.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('user_sales_volumes', function (Blueprint $table) {
|
||||
$table->index(['user_id', 'year', 'month'], 'user_sales_volumes_user_year_month_index');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('user_sales_volumes', function (Blueprint $table) {
|
||||
$table->dropIndex('user_sales_volumes_user_year_month_index');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -11,6 +11,10 @@ oben unter „Stand" stehen erledigte und offene Befunde mit Sprungmarken. Das i
|
|||
**[deploy-2026-09-10.md](deploy-2026-09-10.md)** — was am 10.09. live gegangen ist, was sich dabei in
|
||||
den Daten geändert hat und was das für die historischen Zahlen bedeutet.
|
||||
|
||||
**[deploy-2026-09-14.md](deploy-2026-09-14.md)** — K11: Team-Statistik rechnet wie die Team-Struktur
|
||||
(Provisionsbaum, Provisionspunkte, Umschalter), dazu der Laufstand-Fix in `user_sales_volumes`.
|
||||
Plan und Tickets: [`dev/_tickets/entwicklungsplan-protokoll-september-2026.md`](../_tickets/entwicklungsplan-protokoll-september-2026.md).
|
||||
|
||||
## Entscheidungen, die dauerhaft gelten
|
||||
|
||||
| Dokument | Inhalt |
|
||||
|
|
|
|||
101
dev/2026-07-22/deploy-2026-09-14.md
Normal file
101
dev/2026-07-22/deploy-2026-09-14.md
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# Deploy 14.09.2026 — Team-Statistik = Team-Struktur, Laufstand-Fix
|
||||
|
||||
Das Paket zu K11 aus dem [Entwicklungsplan zum September-Protokoll](../_tickets/entwicklungsplan-protokoll-september-2026.md)
|
||||
(Tickets C1 und C2) ist auf Produktion. Upload per SFTP, anschließend Migration, Snapshot-Neuaufbau,
|
||||
Laufstand-Reparatur und Cache-Leerung im Terminal. **Die Zahlen von Team-Statistik und Team-Struktur
|
||||
stimmen seitdem überein** (Rückmeldung Kevin, 14.09.).
|
||||
|
||||
## Was live gegangen ist
|
||||
|
||||
| Ticket | Inhalt |
|
||||
| --- | --- |
|
||||
| C1 | Die Team-Statistik rechnet standardmäßig mit dem **Provisionsbaum** und den **Provisionspunkten** — denselben Mitgliedern und Punkten wie die Team-Struktur. Die bisherige Sicht (gesamte Downline) bleibt per Umschalter erreichbar. Für abgeschlossene Monate kommen die Mitglieder je Linie aus dem gespeicherten Provisionslauf. |
|
||||
| C2 | Laufstand-Race in `user_sales_volumes` behoben: sperrendes Lesen bei der Neuberechnung, Deadlock-Wiederholung in beiden Zahlungs-Webhooks, tägliches Sicherheitsnetz um 02:45 vor dem Provisionslauf. |
|
||||
|
||||
```bash
|
||||
php artisan migrate
|
||||
php artisan backoffice:store-statistics-snapshots --force
|
||||
php artisan sales:repair-running-totals
|
||||
php artisan config:clear && php artisan view:clear && php artisan cache:clear
|
||||
```
|
||||
|
||||
**Stolperstein beim Migrieren.** Der erste `migrate`-Lauf brach mit „Undefined constant
|
||||
`BackofficeDashboardService::TREE_COMMISSION`" ab: die Migration hatte die Baum-Namen aus der
|
||||
Service-Klasse gelesen, und der PHP-Prozess auf dem Server hielt noch die alte Klasse (Opcache). Es
|
||||
wurde nichts halb angewendet — der Fehler fiel beim Aufbau des Blueprints, vor jedem SQL. Die
|
||||
Migration trägt die Werte jetzt als Literale; Regel für die Zukunft: **Migrationen dürfen nicht vom
|
||||
Anwendungscode abhängen.** Nach Pull, `optimize:clear` und PHP-FPM-Reload lief sie sauber durch.
|
||||
|
||||
## Was sich in den Daten geändert hat
|
||||
|
||||
**Migrationen.**
|
||||
|
||||
- `backoffice_statistics_snapshots` bekommt die Spalte `tree` (`commission` / `downline`); bestehende
|
||||
Snapshots werden als `downline` gekennzeichnet, der Unique-Index läuft jetzt über
|
||||
(`user_id`, `year`, `month`, `tree`).
|
||||
- `user_sales_volumes` bekommt den Index (`user_id`, `year`, `month`), damit die sperrende Lesart nur
|
||||
den Monatsbereich des Beraters sperrt. **Keine vorhandene Zeile wurde verändert.**
|
||||
|
||||
**Snapshot-Neuaufbau.** `backoffice_statistics_snapshots` wurde für alle abgeschlossenen Monate und
|
||||
beide Baum-Definitionen neu geschrieben (Payload-Version 3). Die Provisionsbaum-Variante nimmt die
|
||||
Mitglieder je Linie aus `user_businesses` des jeweiligen Provisionslaufs.
|
||||
|
||||
**Laufstand-Reparatur September.** `sales:repair-running-totals` hat die Laufstände von **2 Usern**
|
||||
(848 und 1543, zusammen **216 Punkte**) neu gerechnet. Nur die kumulativen Spalten der letzten Zeile
|
||||
wurden korrigiert; die Einzelbuchungen waren vollständig. Der laufende Monat rechnet in der Struktur
|
||||
live, die Provision für September wird damit richtig.
|
||||
|
||||
**Nicht angefasst: Juni und August.** Beide Monate hatten je einen User mit Laufstand-Drift (66 bzw.
|
||||
82 Punkte) und sind so abgerechnet worden. **Entscheid 14.09.: keine Nachzahlung, keine
|
||||
Nachberechnung.** Die Laufstände bleiben, wie sie in die Provision eingegangen sind; der Nachtlauf
|
||||
fasst abgeschlossene Monate nicht an (manuell nur mit `--force`).
|
||||
|
||||
**Nicht angefasst:** Provisionen (`user_businesses`), Gutschriften, Rechnungen, Marketingplan.
|
||||
|
||||
## Was das für die Zahlen bedeutet
|
||||
|
||||
Die Kachel „Umsatz (Punkte)" der Team-Statistik zeigt jetzt die **Provisionspunkte**. Gegenüber
|
||||
vorher ist sie **kleiner**, aus drei Gründen, die alle bewusst sind:
|
||||
|
||||
1. **Nur Provisionsbaum.** Berater ohne Mitgliedsebene oder ohne Abrechnungskonto zählen nicht, und
|
||||
ihr Unterbaum ebenfalls nicht — genau wie in der Provisionsberechnung. Die Ansicht zeigt, wie
|
||||
viele Berater der Downline deshalb außen vor sind.
|
||||
2. **Reine Karrierepunkte (KP) zählen nicht** (Neupartner-Einschreibung, manuelle KP-Gutschriften).
|
||||
Sie stehen als eigene Zahl unter der Tabelle: „Nicht enthalten: X Punkte ohne Payline-Wirkung".
|
||||
3. **Negative Monatssalden werden auf 0 gekappt**, wie in der Provision. Auch das steht als eigene
|
||||
Zahl: „Nicht abgezogen: Y Punkte aus negativen Monatssalden".
|
||||
|
||||
Wer die bisherige Sicht braucht, wählt oben „Gesamte Downline". Die Wahl bleibt in der Sitzung
|
||||
erhalten und steckt auch im CSV-Dateinamen.
|
||||
|
||||
Beim Messlauf vor dem Deploy lagen die VIP-Konten im Provisionsbaum bis auf die Laufstand-Drift
|
||||
exakt auf der Struktur. Für August ergab sich nach Umstellung auf die gespeicherte Struktur nur noch
|
||||
eine Differenz von 82 Punkten — genau der Drift-User dieses Monats.
|
||||
|
||||
## Woran man merken würde, dass etwas nicht stimmt
|
||||
|
||||
- **Team-Statistik, Provisionsbaum, laufender Monat:** Summe der Linien muss mit dem Modal „Linie /
|
||||
Punkte / Provision" auf der Struktur-Seite auf den Cent übereinstimmen. Prüfung:
|
||||
`php artisan statistics:compare-trees 9 2026` (Spalte „Diff PB-Struktur" muss 0,00 sein).
|
||||
- **Abgeschlossener Monat:** Abzeichen „Monat abgeschlossen · Stand eingefroren" plus Hinweis, dass
|
||||
die Mitglieder je Linie aus dem gespeicherten Provisionslauf stammen.
|
||||
- **Nachtlauf 02:45:** `sales:repair-running-totals` schreibt bei Drift eine Warnung ins
|
||||
`cron`-Log. Über mehrere Nächte sollte dort „keine Abweichung" stehen — das ist der Nachweis, dass
|
||||
die Sperre auf MySQL wirkt (die Test-Suite läuft auf SQLite und kann den Race nicht nachstellen).
|
||||
- **Zahlungs-Webhooks:** Ein Deadlock würde die ganze Paid-Transaktion bis zu dreimal wiederholen.
|
||||
Erkennbar im `payone`- bzw. `stripe`-Log nur, wenn alle drei Anläufe scheitern; dann stellt der
|
||||
Anbieter erneut zu.
|
||||
|
||||
## Offen
|
||||
|
||||
- **C2, Stufe 3** (Provision aus der Zeilensumme statt aus dem Laufstand der letzten Zeile) gehört
|
||||
zum Ledger-Plan `dev/2026-09-10-business-plan/`.
|
||||
- Rückfrage **R4** (negative Monatssalden in der Provision) ist eine fachliche Entscheidung und
|
||||
wurde nicht angefasst.
|
||||
- Unverändert: die vorbestehenden Testfehler auf dem Branch (92 Fehlschläge in Unit-Tests ohne
|
||||
Datenbank-Setup, z. B. `TreeCalcBotOptimizedTest`, `ConcurrentPaymentTest`; alle mit „no such
|
||||
table" oder `BadMethodCallException` bereits im Setup). Gehören in ein eigenes Aufräumpaket.
|
||||
|
||||
Nicht in diesem Commit und weiterhin nicht committet: die Arbeit aus anderen Themen im selben
|
||||
Arbeitsbaum (DHL, DATEV, Newsletter-Listen, Berater-Übertragung, DSGVO-Löschung, `shipped_at`,
|
||||
Kundenabo-Legenden).
|
||||
BIN
dev/_tickets/2026 09 Protokoll September-komprimiert.pdf
Normal file
BIN
dev/_tickets/2026 09 Protokoll September-komprimiert.pdf
Normal file
Binary file not shown.
16
dev/_tickets/2026-09-14.md
Normal file
16
dev/_tickets/2026-09-14.md
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
ABOs
|
||||
#1 Ausführungstag und sperren
|
||||
Der Tag, an dem das Abo ausgeführt wird, soll beliebig definiert werden Über ein Datum im Monat nicht mehr nur der 5. 10. usw. Sondern 1-31 bzw. bei 31 wird dann immer der letzte Tag genommen 30, 29 ..
|
||||
Das Abo muss aber weiterhin einmal im Monat versendet werden d.h. diesmal im aktuellen Monat kann man natürlich das Datum im Monat so lange verändern bis der Versand raus ist für diesen Monat. Dann bestimmt man den Tag für den nächsten Monat wenn man das ändern möchte. Automatisch wird natürlich der gleiche Tag für den Folgemonat genommen. Damit Monate nicht übersprungen werden können. Kann natürlich ein Datum nur in die Zukunft geändert werden nicht vom zehnten auf den fünften des Monats. Heißt aber vom 10 kann zum Beispiel auf den 24 gestellt werden. Die Einstellung kann jederzeit gemacht werden und wird erst am ausführt Tag fixiert. Heißt, steht es zum Beispiel auf dem 24. kann es am 24. um 0:00 Uhr nicht mehr geändert werden und wir dann auch ausgeführt.
|
||||
|
||||
Produkte ändern hinzufügen bei einem Abo kann genauso gemacht werden. Es wird am ausführt Tag um 0:00 Uhr fixiert. Vorher können Produkte geändert werden. Die Sperre jetzt existiert einige Tage. Vorher soll es nicht mehr geben. Ich würde es trotzdem in der Entwicklung lassen, falls es noch mal irgendwann nötig wird. Vielleicht stellen wir die Sperre einfach auf null oder so.
|
||||
|
||||
Die zusätzlichen Produkte bleiben, wie sie sind. Diese können einige Tage, wie sie eingestellt sind, vorher hinzugefügt werden und auf wieder geändert werden.
|
||||
|
||||
#2
|
||||
Es muss für Berater Admin oder für den User selbst deutlich ersichtlich sein warum ein Abo nicht versendet wird und wie der Status ist. Ist es Aktiv und wurde ordnungsgemäß ausgeführt, ist es in Aktiv beziehungsweise pausiert und bis wann, ist eine Zahlung nicht rausgegangen und warum zumindest hinschreiben, dass die Zahlung nicht funktioniert hat und so weiter.
|
||||
|
||||
#3
|
||||
Hat ein Abo seine Mindestausführungen erreicht. Derzeit sechs Monate kann es pausiert oder auch gelöscht werden.
|
||||
Hier beim Löschen muss noch einmal deutlich geschrieben werden, was das Aufsicht hat, dass bei einem neuen Abo wieder die sechs Ausführungen nötig sind Und ob man das Abo nicht erst mal pausieren möchte.
|
||||
|
||||
435
dev/_tickets/entwicklungsplan-protokoll-september-2026.md
Normal file
435
dev/_tickets/entwicklungsplan-protokoll-september-2026.md
Normal file
|
|
@ -0,0 +1,435 @@
|
|||
# Entwicklungsplan – Protokoll Zoom-Meeting 08.09.2026
|
||||
|
||||
**Stand:** 14.09.2026 · **Status:** C1 (Team-Statistik) umgesetzt, Deploy offen; nächstes Paket: Abo (A4 → A3 → A1+A2)
|
||||
**Quellen:** `2026 09 Protokoll September-komprimiert.pdf` (Protokoll), `2026-09-14.md` (Ticket-Entwurf Abos #1–#3)
|
||||
**Verwandte Pläne:** `dev/2026-07-22/entwicklungsplan-2026-09-07.md` (K11–K14 behandeln dieselben Themen aus dem Kundenbrief; die dortigen Rückfragen F4–F7 sind weiterhin offen), `dev/2026-09-10-business-plan/` (Ledger-Plan, löst A07/K11 langfristig)
|
||||
|
||||
---
|
||||
|
||||
## 0. Übersicht und empfohlene Reihenfolge
|
||||
|
||||
| # | Thema | Quelle | Aufwand | Risiko | Blockiert durch |
|
||||
|---|---|---|---|---|---|
|
||||
| **A1** | Abo-Ausführungstag frei wählbar (1–31, Monatsende-Regel) | Ticket #1, Protokoll | 3–4 PT | mittel | – |
|
||||
| **A2** | Änderungssperre erst ab 0:00 Uhr am Ausführungstag | Ticket #1 | 1 PT | niedrig | zusammen mit A1 |
|
||||
| **A3** | Abo-Status und Grund sichtbar (Zahlung / Kunde / Admin) | Ticket #2, Protokoll | 2–3 PT | niedrig | – |
|
||||
| **A4** | Pausieren/Kündigen beschriften, Kündigungs-Hinweis (6 Ausführungen) | Ticket #3, Protokoll | 1 PT | niedrig | – |
|
||||
| **A5** | Kundenabo → Partnerabo automatisch umwandeln | Protokoll | 4–6 PT | hoch | Rückfragen R5 |
|
||||
| **B1** | Jahresgebühr automatisch abbuchen (Opt-in-Häkchen) | Protokoll | 6–8 PT | hoch | Rückfragen R6 |
|
||||
| **B2** | Berater fällt aus: Pausieren nach 2 Tagen, Rückgabe bei Zahlung, harter Cut nach 4 Wochen, Sponsor-Mail | Protokoll, Rückmeldung 14.09. | 3–4 PT | mittel (einmaliger Massen-Effekt, akzeptiert) | – |
|
||||
| **C1** | Team-Statistik = Team-Struktur (gleiche Punkte) – **live seit 14.09.** ([Deploy-Notiz](../2026-07-22/deploy-2026-09-14.md)) | Protokoll | 3–5 PT | hoch (jede Zahl ändert sich) | – (F4: Umschalter) |
|
||||
| **C2** | Laufstand-Race: Provision liest zu niedrigen Monatsstand bei zeitgleichen Buchungen – **Stufe 1+2 live seit 14.09.**, Stufe 3 offen | Befund aus C1 | 0,5 + 1 PT | hoch (Provision) | – (keine Nachzahlung, Entscheid 14.09.) |
|
||||
| **D1** | Neuer Reiter „Events“ mit Foto-Galerien (wie News-Archiv) | Protokoll | 2–3 PT | niedrig | Fotos liegen vor |
|
||||
| **D2** | Top 3 firmenweit im Backoffice | Protokoll | 0,5 PT | rechtlich | Network-Anwalt (F3) |
|
||||
| **E1** | Recruiting-Challenge Okt–Jan (50 €/Neupartner, Top 3) | Protokoll | 0,5 PT Konfiguration + 1–2 PT Erweiterung | niedrig | Details Dani/Alois |
|
||||
|
||||
**Reihenfolge (festgelegt 14.09.):**
|
||||
|
||||
1. **Team-Statistik zuerst (C1)** – die Seite wird ständig angeschaut, und die abweichenden Zahlen erzeugen laufend Rückfragen der Kundin. Schritt 1 (Mess-Command) und Schritt 2 (gemeinsames Baum-Prädikat) können sofort starten; nur die Umstellung selbst (Schritt 3) braucht vorher die Antwort auf F4 (abgelaufene Berater: Empfehlung Provisionssicht als Standard mit Umschalter „inkl. abgelaufene“).
|
||||
2. **Abo-Paket** – A4 → A3 → A1+A2 (A1 und A2 greifen ineinander, gemeinsam testen).
|
||||
3. **Termingebunden** – E1 (Challenge startet 01.10., Nachtragen ist möglich, s. u.), B2 (Ausfall-Ablauf komplett in einem Paket: Pausieren, Rückgabe, harter Cut, Sponsor-Mails – nicht in Teilen ausliefern, sonst entstehen Zwischenzustände ohne Rückweg).
|
||||
4. **Backoffice** – D1 (Events-Reiter).
|
||||
5. **Groß, mit offenen Rückfragen** – B1, A5. Ohne die Antworten in Abschnitt 6 nicht beginnen.
|
||||
|
||||
**Gesamtaufwand grob:** 25–36 PT, davon ~10 PT im Abo-Paket.
|
||||
|
||||
---
|
||||
|
||||
## 1. Abo-Paket
|
||||
|
||||
### A1 – Ausführungstag frei wählbar (1–31)
|
||||
|
||||
**Ziel (Ticket #1):** Jeder Tag 1–31 wählbar; bei 29/30/31 in kürzeren Monaten der letzte Monatstag. Genau eine Ausführung pro Monat. Änderung nur „nach vorne“ (10. → 24. ja, 10. → 5. nein, wenn der 5. schon vorbei ist). Wird am Ausführungstag 0:00 Uhr fixiert (→ A2).
|
||||
|
||||
**Ist-Stand**
|
||||
|
||||
- Einzige Quelle der erlaubten Tage: [`UserAbo::$aboDeliveryDays = [5, 10, 20, 25]`](../../app/Models/UserAbo.php#L228). Alle Verbraucher lesen dieses Array: Select-Optionen [`HTMLHelper::getAboDeliveryOptions()`](../../app/Services/HTMLHelper.php#L114-L127), Validierung [`AboRepository::validate()`](../../app/Repositories/AboRepository.php#L270) und [`Portal\AboController`](../../app/Http/Controllers/Portal/AboController.php#L748), Admin-Filter [`Admin\AboController`](../../app/Http/Controllers/Admin/AboController.php#L44), Prognosen [`PaylineForecastService`](../../app/Services/Abo/PaylineForecastService.php#L296) und [`TeamAboOverviewService`](../../app/Services/Abo/TeamAboOverviewService.php#L438).
|
||||
- Der Tag steht in `user_abos.abo_interval` (TinyInt, trotz des Namens ein Monatstag) – **kein Schema-Change nötig**, bestehende Werte 5/10/20/25 bleiben gültig.
|
||||
- **Monatsende-Problem:** [`AboHelper::setNextDate()`](../../app/Services/AboHelper.php#L269-L275) rechnet `firstOfMonth()->addDays($tag - 1)`. Für Tag 31 im Februar landet das im März, für Tag 30 im Februar auf dem 1./2. März → **das Abo würde einen Monat überspringen oder doppelt laufen**. Gleiches Muster in [`AboRepository::calculateAdminNextDate()`](../../app/Repositories/AboRepository.php#L341-L346). Die richtige Klammerung existiert bereits in [`advanceAboCandidateOneMonth()`](../../app/Services/AboHelper.php#L367-L374) (`min($tag, daysInMonth)`), wird aber nur für die Erst-Ausführung genutzt.
|
||||
- Änderungslogik heute: [`calculateNewNextDate()`](../../app/Repositories/AboRepository.php#L324-L339) nimmt den Monat von `next_date` als Referenz (liegt `next_date` schon im Folgemonat, landet der neue Tag im Folgemonat). Eine „nur nach vorne“-Regel gibt es nicht; stattdessen verlangt die Sperre 10 Tage Abstand.
|
||||
- Erst-Ausführung: [`MIN_DAYS_UNTIL_FIRST_ABO_EXECUTION = 10`](../../app/Services/AboHelper.php#L27) – bleibt.
|
||||
|
||||
**Umsetzung**
|
||||
|
||||
1. `UserAbo::$aboDeliveryDays` durch `UserAbo::allowedDeliveryDays(): array` (= `range(1, 31)`) ersetzen; alle sieben Verwendungen umstellen. Select bekommt 31 Einträge, Label „31. (bzw. letzter Tag des Monats)“ für 29–31 ([`getAboStrLang()`](../../app/Services/HTMLHelper.php#L129-L132)).
|
||||
2. Zentrale Datumsfunktion `AboHelper::dateForDayInMonth(Carbon $monat, int $tag)` mit `min($tag, daysInMonth)`; `setNextDate()`, `calculateAdminNextDate()`, `computeFirstAboCandidateWithoutMinDays()` darauf umstellen. Wichtig: der Folgemonat wird immer aus `abo_interval` berechnet, nie aus dem geklammerten Vortermin (sonst bleibt ein 31er-Abo nach dem Februar dauerhaft auf dem 28.).
|
||||
3. Neue Änderungsregel in `AboRepository::validate()` (ersetzt die 10-Tage-Prüfung `error_abo_interval_too_soon`):
|
||||
- Referenzmonat = Monat von `next_date` (wie heute).
|
||||
- Neues Datum = gewählter Tag in diesem Monat (geklammert).
|
||||
- Muss **nach heute** liegen (`> today`), sonst Fehler „Der :tag. ist in diesem Monat schon vorbei – die Änderung gilt ab dem nächsten Monat, bitte nach der Lieferung erneut einstellen“ (oder alternativ: automatisch für den Folgemonat setzen – **Rückfrage R1**).
|
||||
- Der Monat darf sich durch die Änderung **nie** verschieben → garantiert „einmal pro Monat, kein Monat übersprungen“.
|
||||
4. Admin-Modal ([`modal_abo_update.blade.php:44-63`](../../resources/views/admin/abo/modal_abo_update.blade.php#L44-L63)): Monat 0–3 voraus bleibt, Tag 1–31.
|
||||
5. Admin-Filter „nächste Ausführung am …“: statt Dropdown mit 31 Werten ein Datumsfilter (von/bis) auf `next_date`.
|
||||
6. **Prognose-Kacheln** (frisch ausgeliefert, Commits `8f4e105`, `4062c46`): „Payline-Prognose zum 5. / 10. / 20. / 25.“ ([home.php:53](../../resources/lang/de/home.php#L53)) und „Punkte aus meinen Kundenabos zum 5. / 10. / 20. / 25.“ ([abo.php:55](../../resources/lang/de/abo.php#L55)) benutzen die Tage als Stichtage. Die Stichtage funktionieren mit beliebigen `next_date`-Werten weiter, **aber Abos nach dem 25. fallen aus der letzten Spalte** → Spalte „Monatsende“ ergänzen und die Überschriften anpassen. `TeamAboOverviewService::438` (`max(aboDeliveryDays)`) auf Monatsende umstellen.
|
||||
7. Sprachdateien de/en/es/fr (`LocalizationSyncTest` erzwingt Parität).
|
||||
|
||||
**Tests:** `AboRepositoryDateChangeTest` (bestehende 6 Tests anpassen), neu: Klammerung 29/30/31 in Feb/Apr/Jun/Sep/Nov, Folgemonat nach Klammerung wieder auf 31, „nach vorne“-Regel, kein Monatssprung, Prognose-Spalte Monatsende, `UserMakeAboOrder` läuft an Tag 31 im 30-Tage-Monat am 30.
|
||||
|
||||
**Machbarkeit / Risiken**
|
||||
|
||||
- Technisch gut machbar; die zentrale Stelle ist eine Zeile plus die Datumsklammerung.
|
||||
- **Fachliches Risiko Logistik:** Heute gibt es vier Versandtage, das Lager packt gebündelt (das war der Grund für die 10-Tage-Sperre). Mit freien Tagen kommen täglich Abo-Bestellungen um 04:00 Uhr. Das ist ausdrücklich Alois' Wunsch („Teufelsküche an den Abo-Tagen“), sollte aber dem Lager/Versand bewusst sein.
|
||||
- **Rückfrage R1:** Wenn der gewünschte Tag im laufenden Monat schon vorbei ist – Fehlermeldung, oder Änderung automatisch für den Folgemonat übernehmen (dann würde diese Monatslieferung noch am alten Tag laufen)?
|
||||
- Bestehende Abos ändern sich nicht; keine Datenmigration.
|
||||
|
||||
### A2 – Änderungssperre erst am Ausführungstag ab 0:00 Uhr
|
||||
|
||||
**Ziel (Ticket #1):** Tag und Produkte bis 23:59 Uhr des Vortags änderbar, dann fixiert. Die Sperre bleibt im Code (Setting), Default effektiv 0. Einmalprodukte behalten ihr eigenes Fenster (4 Tage vorher).
|
||||
|
||||
**Ist-Stand**
|
||||
|
||||
- Zwei Sperren in [`AboRepository`](../../app/Repositories/AboRepository.php#L13-L39): `abo-lock-days-change` (Default 10, Tag + Produkte) und `abo-lock-days-pause-cancel` (Default 3). Beide Getter fallen bei **0 auf den Default zurück** (`$days > 0 ? $days : DEFAULT`) – `AboLockDaysSettingTest::32` sichert das sogar ab. **Sperre auf 0 stellen funktioniert heute nicht.**
|
||||
- Bedingung `daysUntil < lockDays` mit `now()->diffInDays(next_date)`: am Ausführungstag selbst ist `daysUntil = 0`, `0 < 0` = falsch → **am Ausführungstag vor 04:00 wäre eine Änderung heute sogar erlaubt**, obwohl der Cron gleich läuft.
|
||||
- UI-Spiegel [`AboHelper::isAboChangeLocked()`](../../app/Services/AboHelper.php#L204-L213), Produkt-Guards `$changeLocked` in [`User\AboController:206-286`](../../app/Http/Controllers/User/AboController.php#L206) und [`Portal\AboController:110-131`](../../app/Http/Controllers/Portal/AboController.php#L110). Alle greifen auf den Getter zu → eine Änderung reicht.
|
||||
- Einmalprodukte: eigenes, gegenläufiges Fenster `abo-onetime-window-days` (nur in den letzten 4 Tagen erlaubt) – unberührt.
|
||||
|
||||
**Umsetzung**
|
||||
|
||||
1. Getter-Semantik: Setting **leer/nicht vorhanden** → Default; Setting **`0`** → 0 (explizit erlaubt). Test „fällt bei 0 auf den Default zurück“ entsprechend umschreiben.
|
||||
2. Sperrbedingung auf Kalendertage umstellen (`AboHelper::calendarDaysUntil()` existiert) und als „gesperrt ab 0:00 Uhr des Tages `next_date − lockDays`“ definieren, also `daysUntil <= lockDays`. Mit `lockDays = 0` ist das exakt die Ticketregel. Achtung: für einen gepflegten Wert >0 verschiebt sich die Sperre damit um einen Tag nach vorn (heute: 10 Tage = Tag 0–9, neu: Tag 0–10). Da der Wert auf 0 gesetzt wird, ist das praktisch irrelevant, muss aber in Tests und Fehlertext (`error_change_locked`) sauber sein.
|
||||
3. Setting `abo-lock-days-change` in den Admin-Einstellungen auf 0 setzen (Deploy-Schritt, kein Code). Default-Konstante auf 0 ändern, damit ein Test-/Neusystem gleich richtig läuft.
|
||||
4. **Rückfrage R2:** Gilt „keine Sperre mehr“ auch für Pausieren/Kündigen (heute 3 Tage)? Empfehlung: ja, gleiche Regel (bis 23:59 Uhr des Vortags), sonst ist es für Kunden nicht erklärbar.
|
||||
5. Hinweistexte („Änderungen müssen mindestens :lock Tage vorher erfolgen“) für `lock = 0` umformulieren: „Änderungen sind bis zum Vortag der Lieferung möglich.“
|
||||
|
||||
**Tests:** `AboLockDaysSettingTest` anpassen (0 = keine Vorlaufsperre, Sperre am Ausführungstag selbst greift, Vortag 23:59 frei), `AboOneTimeWindowTest` unverändert grün.
|
||||
|
||||
**Risiko:** niedrig. Zeitliche Lücke 0:00–04:00 Uhr ist durch die neue Bedingung geschlossen.
|
||||
|
||||
### A3 – Status und Grund eines Abos sichtbar machen
|
||||
|
||||
**Ziel (Ticket #2, Protokoll):** Für Admin, Berater und Kunde sofort erkennbar: aktiv und ordnungsgemäß ausgeführt / pausiert bis wann und durch wen / Zahlung fehlgeschlagen (und dass sie fehlgeschlagen ist) / gekündigt durch wen.
|
||||
|
||||
**Ist-Stand**
|
||||
|
||||
- Status-Codes [`UserAbo::$statusTypes`](../../app/Models/UserAbo.php#L230-L240): 2 okay, 3 angehalten (= Zahlung fehlgeschlagen, gesetzt in [`UserMakeAboOrder::updateAboOnError()`](../../app/Console/Commands/UserMakeAboOrder.php#L414-L470)), 4 storniert, 5 beendet, 6 inaktiv (nur Admin), 7 kulanz, 8 extern erledigt.
|
||||
- **Pause hat keinen Status:** Pausieren verschiebt nur `next_date` um einen Zyklus, setzt `last_pause_date`/`pause_count`, Status bleibt 2 ([`pauseOneMonthLocked()`](../../app/Repositories/AboRepository.php#L188-L232)). In allen Listen sieht ein pausiertes Abo aus wie ein laufendes – genau Alois' Problem im Büro.
|
||||
- Wer etwas getan hat, steht nur in `user_abo_status_histories` (`action`, `view` = me/ot/portal/admin, `actor_user_id`, alte/neue `next_date`) – wird nirgends angezeigt.
|
||||
- Zahlungsfehler: `user_abo_orders` hat nur `status` + `paid`, **keine Fehlermeldung**. Der Grund (Karte abgelaufen, Deckung, PayPal) geht verloren.
|
||||
- Anzeigen: Detail [`_detail.blade.php:27-28`](../../resources/views/admin/abo/_detail.blade.php#L27) (Status-Badge + Aktiv-Badge), Listen `user/abo/index`, `user/abo/_customer_abo_list`, `user/team/customer_abos`, Admin-Datatable, Portal `portal/abo/my_abo`.
|
||||
|
||||
**Umsetzung**
|
||||
|
||||
1. **Datenmodell (Migration):** `user_abos.status_reason` (string, nullable; Werte `payment_failed`, `paused_by_customer`, `paused_by_consultant`, `paused_by_admin`, `cancelled_by_customer`, `cancelled_by_consultant`, `cancelled_by_admin`, `deactivated_by_admin`, `pending_initial_payment`), `user_abos.paused_until` (date, nullable), `user_abo_orders.error_message` (string, nullable). Backfill-Command aus `user_abo_status_histories` für Bestandsdaten.
|
||||
2. **Schreiben** an den bestehenden Stellen: `updateAboOnError()` (Fehlertext aus der Payone-/Stripe-Antwort mitnehmen), `AboRetryPaymentService::markAboError()/markAboSuccess()`, `pauseOneMonthLocked()` (`paused_until = neues next_date`, Grund aus `view`), `updateStatus()` (Kündigung/Deaktivierung), Erfolgs-Ausführung setzt `status_reason = null`, `paused_until = null`.
|
||||
3. **Lesen:** Service `AboStatusExplainer::explain(UserAbo): AboStatusInfo` (Label, Farbe, Erklärungstext, Datum, Akteur, empfohlene Aktion). Beispiele: „Pausiert bis 24.10.2026 – vom Kunden am 12.09.2026“, „Zahlung am 10.09.2026 fehlgeschlagen (Karte abgelaufen) – Abo läuft nicht, bis die Zahlung nachgeholt ist“, „Gekündigt am 01.08.2026 durch Berater“, „Aktiv – letzte Lieferung 10.09.2026, nächste 10.10.2026“.
|
||||
4. **Anzeige** in allen sechs Listen/Detailseiten über ein Blade-Partial `abo/_status_explain.blade.php`; im Admin-Datatable als eigene Spalte + Filter „pausiert“ / „Zahlung fehlgeschlagen“.
|
||||
5. Pausen-Badge auch ohne Migration herleitbar (`pause_count > 0 && last_pause_date > last_date`) – als Fallback für alte Daten.
|
||||
6. Sprachdateien de/en/es/fr.
|
||||
|
||||
**Tests:** Explainer-Unit-Tests je Zustand, Feature-Test: Pause → Liste zeigt „pausiert bis“, Zahlungsfehler → Fehlermeldung gespeichert und angezeigt, Kündigung durch Portal → „durch Kunde“.
|
||||
|
||||
**Risiko:** niedrig. Reine Zusatzinformation, keine Verhaltensänderung.
|
||||
|
||||
### A4 – Pausieren/Kündigen beschriften, Kündigungs-Hinweis
|
||||
|
||||
**Ziel (Ticket #3, Protokoll):** Im Partnershop/Portal sofort sichtbar, dass und wo man pausieren/kündigen kann. Beim Kündigen deutlicher Hinweis: neues Abo braucht wieder 6 Ausführungen; Vorschlag, stattdessen zu pausieren.
|
||||
|
||||
**Ist-Stand:** Buttons im Modal [`modal_abo_update.blade.php:118-142`](../../resources/views/admin/abo/modal_abo_update.blade.php#L118) nur sichtbar, wenn `canCancel` (Mindestlaufzeit erreicht, [`AboHelper::canCancelAbo()`](../../app/Services/AboHelper.php#L125-L134), Setting `abo-min-duration` = 6 **bezahlte** Ausführungen). Kündigung = Status 4 nach JS-`confirm()`. Echtes Löschen gibt es nur für Admins.
|
||||
|
||||
**Umsetzung**
|
||||
|
||||
1. Eigener Abschnitt „Abo pausieren oder kündigen“ auf der Detailseite (Portal + Berater), nicht nur im Bearbeiten-Modal: Fortschritt „4 von 6 Lieferungen“, ab Erreichen die beiden Aktionen mit Erklärung.
|
||||
2. Kündigung über ein Bootstrap-Modal statt `confirm()`: Text mit Konsequenzen (Abo endet, Punkte/Konditionen entfallen, ein neues Abo beginnt wieder mit 6 Pflichtlieferungen), Button „Lieber 1 Monat pausieren“ als primäre Aktion, „Trotzdem kündigen“ mit Bestätigungs-Checkbox (Muster aus `CustomerDeleteTest` / Kunden-löschen-Modal).
|
||||
3. Vor Erreichen der Mindestlaufzeit: Hinweis, ab wann pausieren/kündigen möglich ist (Text `info_min_duration_orders_left` existiert).
|
||||
4. Sprachdateien de/en/es/fr.
|
||||
|
||||
**Risiko:** keins. 1 PT.
|
||||
|
||||
### A5 – Kundenabo → Partnerabo automatisch umwandeln
|
||||
|
||||
**Ziel (Protokoll):** Nach der Partnerregistrierung erkennt das System ein laufendes Kundenabo → Umwandlung anbieten, Zahlung neu bestätigen, Kompensationsprodukte wählen, **Mindestlaufzeit läuft weiter** (3 Lieferungen als Kunde + Monat 4 als Partner).
|
||||
|
||||
**Ist-Stand**
|
||||
|
||||
- Unterschied Kunden-/Partnerabo ist nur `user_abos.is_for` (`ot` / `me`), plus Zuordnung `user_id`/`member_id`/`shopping_user_id` ([`AboHelper::createNewAbo()`](../../app/Services/AboHelper.php#L438-L446)). Zahlungs-Token (`payone_userid`, `stripe_customer_id`) hängen am Abo.
|
||||
- Ausführungszähler [`getCountPaidOrders()`](../../app/Models/UserAbo.php#L517-L533) zählt `user_abo_orders` **desselben Abos** → eine Umwandlung **im selben Datensatz** erfüllt „Mindestlaufzeit läuft weiter“ automatisch. Ein neues Abo würde bei 0 beginnen – deshalb In-Place-Umwandlung.
|
||||
- Kompensationsprodukte: `user_abo_items.comp`, Sortiment über [`Shop::getCompProducts('abo-me')`](../../app/Services/Shop.php#L270-L309) (`show_on = 12`), gesteuert über Setting `is_comp_me_abo`. Für `ot`-Abos gibt es heute keine Kompensation.
|
||||
- **Es existiert kein Umwandlungspfad.** [`Web\RegisterController`](../../app/Http/Controllers/Web/RegisterController.php#L78-L136) legt nur den `User` an und schaut nicht nach `ShoppingUser`/`UserAbo`. Wiederverwendbar: `AboPaymentMethodService` (Zahlungsart neu hinterlegen), `AboItemHistoryService`, `UserAboStatusHistory`, `AboMoveOrders` (Muster).
|
||||
|
||||
**Umsetzung (In-Place)**
|
||||
|
||||
1. Erkennung: nach Freischaltung des Partners (`wizard = 100`, bezahlte Registrierung) aktive `ot`-Abos mit gleicher E-Mail (`user_abos.email` bzw. `shopping_users.billing_email`) suchen → Dashboard-Banner „Kundenabo in Partnerabo umwandeln – 30 % günstiger“.
|
||||
2. Umwandlungsseite (CRM, `user/abos/convert/{abo}`): Vorschau der Positionen mit Partnerpreisen, Kompensationsprodukte als Slots (`comp > 0`) wählbar, Summe/Punkte neu.
|
||||
3. Zahlung bestätigen: neuen Token über den bestehenden Zahlungsart-ändern-Flow (`AboPaymentMethodService`, Payone/Stripe je `PaymentProviderManager`), da Betrag und Vertragspartei wechseln.
|
||||
4. Schreiben in einer Transaktion: `is_for = 'me'`, `user_id = neuer User`, `member_id` wie bei `me`-Abos, `shopping_user_id` = eigener `ShoppingUser` mit `auth_user_id`, Items ersetzen, Preise neu snapshotten, `UserAboStatusHistory` neue Aktion `converted`, Item-History. `user_abo_orders` bleiben → Zähler läuft weiter.
|
||||
5. **Nicht** `IncentiveTracker::trackAboActivated()` auslösen (kein neues Abo), Ein-Abo-Regel (`AboHelper::userHasAbo()`) berücksichtigen.
|
||||
|
||||
**Machbarkeit / Risiken (hoch – Freigabe vor Start nötig)**
|
||||
|
||||
- **Provisions-/Statistikwirkung:** Der Umsatz wandert von den Kundenpunkten des Beraters in die Eigenpunkte des Neupartners (Ebene 1 des Sponsors). Das verschiebt Bonus-Kategorien, Kundenabo-Zählungen (`TeamAboOverviewService`, Incentive `incentive_new_abos`) und die Kundenabo-Prognose. Muss kommuniziert werden.
|
||||
- **Kompensationsprodukte ohne Freigabe** = jeder Partner kann sich Kompensation eintragen. Entweder Admin-Freigabe (Status „Umwandlung beantragt“) oder nur das reguläre Comp-Sortiment ohne Begründungsfeld.
|
||||
- Zahlungs-Token gehört rechtlich dem Kunden als Person – dieselbe Person, aber der Vertrag ändert sich → deshalb Neubestätigung (Protokoll fordert das ohnehin).
|
||||
- E-Mail der Registrierung ≠ E-Mail des Abos → Erkennung schlägt fehl; Admin braucht einen manuellen Umwandlungsknopf als Fallback.
|
||||
- Preis-Snapshots der Items (`user_abo_one_time_items` haben Snapshots, Abo-Items rechnen live) – prüfen, wie der Abo-Betrag beim nächsten Lauf gebildet wird (`getFormattedAmount()`).
|
||||
|
||||
**Rückfragen R5:** (a) Wer gibt Kompensationsprodukte frei? (b) Sponsor des Neupartners = bisheriger Berater des Kunden automatisch? (c) Darf der Partner den Umwandlungszeitpunkt frei wählen (sofort vs. ab nächster Lieferung)?
|
||||
|
||||
---
|
||||
|
||||
## 2. Mitgliedschaft / Jahresgebühr
|
||||
|
||||
### B1 – Jahresgebühr automatisch abbuchen (Opt-in-Häkchen)
|
||||
|
||||
**Ziel (Protokoll):** Berater setzen ein Häkchen → Jahresgebühr wird automatisch abgebucht. Ohne Häkchen weiterhin Erinnerungsmails und manuelle Zahlung. (Das ist Opt-in – entschärft die rechtliche Frage F6 aus dem Septemberbrief, die Opt-out vorsah.)
|
||||
|
||||
**Ist-Stand**
|
||||
|
||||
- Mitgliedschaft = `users.payment_account` (bezahlt bis), Verlängerung + Reaktivierung zentral in [`Payment::paymentStatusPaidAction()`](../../app/Services/Payment.php#L192-L233) (Produkt-Action `payment_for_account`). Verlängerungsprodukt `show_renewal` seit Commit `c3dc7eb`; Produkttext sagt ausdrücklich „Eine automatische Verlängerung gibt es nicht“.
|
||||
- Manuelle Zahlung: [`MembershipController::storePayment()`](../../app/Http/Controllers/User/MembershipController.php#L125-L186) → normaler Checkout.
|
||||
- Erinnerungen: [`CheckPaymentsAccount`](../../app/Console/Commands/CheckPaymentsAccount.php) (Stufen 31/33/34/35, dedupliziert über `UserHistory`), Config `mivita.remind_*`.
|
||||
- **Berater haben kein gespeichertes Zahlungsmittel.** Keine Token-Spalte auf `users`; `user_accounts.payment_data` enthält nur alte ELV-Daten (SEPA abgeschafft). Wiederkehrende Token existieren nur an Abos (`payone_userid`, `stripe_customer_id`) und `shopping_users.stripe_customer_id`.
|
||||
- Wiederverwendbar: Off-Session-Charge [`UserMakeOrder::makePayment()`](../../app/Cron/UserMakeOrder.php#L69-L98), [`StripeGateway::chargeAbo()`](../../app/Services/Payment/StripeGateway.php#L26-L40) (auf `UserAbo` typisiert → verallgemeinern), Retry-Muster `AboRetryPaymentService`, Karten-Hinterlegung `AboPaymentMethodService`. Per-User-Flag: `users.settings` (`User::setSetting()`), keine Migration nötig für das Häkchen selbst.
|
||||
- Alte Texte für genau dieses Feature liegen ungenutzt in [payment.php:15-18](../../resources/lang/de/payment.php#L15-L18).
|
||||
|
||||
**Umsetzung**
|
||||
|
||||
1. Migration: `users.renewal_payment_provider`, `renewal_payment_token` (Payone `userid` bzw. Stripe `customer_id` + `payment_method_id`), `renewal_auto_at` (optional).
|
||||
2. Mitgliedschaftsseite: Checkbox „Jahresgebühr automatisch abbuchen“ → führt in einen „Zahlungsart hinterlegen“-Flow (0-€-/Verifizierungs-Transaktion bei Payone bzw. Stripe SetupIntent `usage: off_session`), Mandats-/Einwilligungstext, jederzeit abwählbar (Token löschen).
|
||||
3. Service `MembershipRenewalService::charge(User)`: erstellt Verlängerungs-`ShoppingOrder` (Logik aus `MembershipController::storePayment()` herauslösen), bucht off-session über den gespeicherten Token, Erfolg → bestehender `paymentStatusPaidAction`-Pfad (verlängert, reaktiviert, Rechnung wie jede Bestellung).
|
||||
4. Command `membership:auto-renew` (täglich ~02:30, vor `user:cleanup`): Kandidaten = Häkchen gesetzt, `payment_account − X Tage <= heute`, keine Verlängerungsbestellung im Zeitraum. Fehler → Mail „Abbuchung fehlgeschlagen“ + Rückfall in die normalen Erinnerungsstufen, Retry täglich bis Ablauf.
|
||||
5. `CheckPaymentsAccount`: Berater mit Häkchen bekommen statt Stufe 31 eine Info „wir buchen am X ab“ und nach Erfolg eine Bestätigung; Stufen 33–35 nur bei gescheiterter Abbuchung.
|
||||
6. Produkt-/Seitentexte („keine automatische Verlängerung“) anpassen, de/en/es/fr.
|
||||
|
||||
**Machbarkeit / Risiken (hoch)**
|
||||
|
||||
- Zwei Zahlungsanbieter parallel (Payone/Stripe, `PaymentProviderManager`): Empfehlung, den für **neue** Zahlungen aktiven Anbieter zu nehmen und nur dessen Token-Flow zu bauen.
|
||||
- SCA/3-D-Secure: Off-Session-Abbuchungen können abgelehnt werden → Fallback auf Zahlungslink-Mail (Muster `AboPaymentFailedMail`).
|
||||
- Rechtlich: Opt-in mit ausdrücklichem Mandatstext und Widerruf; keine rückwirkende Anwendung auf Bestandsberater.
|
||||
- Rechnungsstellung läuft über die normale Bestellung – prüfen, dass die Verlängerungsbestellung ohne Warenkorb-Session sauber entsteht (Muster `UserMakeOrder::makeShoppingOrder()`).
|
||||
|
||||
**Rückfragen R6:** (a) Wie viele Tage vor Ablauf abbuchen (alter Text: 14)? (b) Nur Stripe, nur Payone, oder je nach Anbieterstatus? (c) Soll die Abbuchung auch Berater reaktivieren, die schon pausiert sind (Stufe 1 in B2)?
|
||||
|
||||
### B2 – Berater fällt aus: Pausieren, Rückgabe bei Zahlung, harter Cut, Sponsor-Mail
|
||||
|
||||
**Ziel (Protokoll + Rückmeldung 14.09.):**
|
||||
|
||||
1. Zahlung bleibt aus → Account wird pausiert / inaktiv.
|
||||
2. Kurz danach (Versatz ca. 2 Tage) wandern **Kunden und Team** zum Sponsor, damit die Punkte bei ihm eingehen.
|
||||
3. Zahlt der Berater innerhalb von 4 Wochen, wird er reaktiviert und bekommt Kunden und Team zurück. Umsätze und Provisionen der Zwischenzeit bleiben beim Sponsor.
|
||||
4. Nach 4 Wochen harter Cut wie heute: alles bleibt endgültig beim Sponsor, der Berater wird entfernt. Zu diesem Zeitpunkt bekommt der Sponsor die Mail mit beiden Listen (Vorname, Nachname, Telefon, E-Mail).
|
||||
|
||||
#### Antwort auf die Frage „passiert bei Ablauf schon etwas?“ – Ist-Ablauf heute
|
||||
|
||||
| Zeitpunkt | Was passiert | Was **nicht** passiert |
|
||||
|---|---|---|
|
||||
| Tag 0 – `payment_account` abgelaufen | Mail Stufe 35 „Account pausiert“ ([`CheckPaymentsAccount`](../../app/Console/Commands/CheckPaymentsAccount.php)) | **Technisch nichts.** `active` bleibt 1, `m_sponsor` bleibt. Der Berater steht weiter im Provisionsbaum: [`readParentsBusinessUsers()`](../../app/Services/BusinessPlan/BusinessUserItemOptimized.php#L1088) prüft nur `payment_account != null`, nicht das Datum. Das Flag `active_account` in `user_businesses` ist reine Anzeige/Filter (`BusinessControllerOptimized`) und blockiert keine Provision. Kunden- und Abo-Umsätze laufen 14 Tage weiter auf ihn – und werden ihm ausgezahlt (die Mail 35 verspricht das sogar). |
|
||||
| Tag +14 – [`cleanUpInActiveUser()`](../../app/Console/Commands/UserCleanUp.php#L145) | Teampartner → nächster aktiver Sponsor (`user_clean_up_logs`, umkehrbar), `active = 0`, `pre_sponsor` gemerkt, `m_sponsor = null` → raus aus dem Baum. Login bleibt möglich (Mitgliedschaft bezahlen). | **Kunden bleiben beim inaktiven Berater** (Kommentar im Code sagt das ausdrücklich). Da er nicht mehr im Baum hängt, kommen deren Punkte bei **niemandem** an – weder bei ihm noch beim Sponsor. |
|
||||
| Tag +14 bis +60 | Zahlung → [`Payment::paymentStatusPaidAction()`](../../app/Services/Payment.php#L227) → [`reactiveUserResetChilds()`](../../app/Services/UserUtil.php#L275): `active = 1`, Sponsor zurück, Teampartner aus den Logs zurück | Kunden müssen nicht zurück – sie waren nie weg. |
|
||||
| Tag +60 – [`deleteInactiveUsers()`](../../app/Console/Commands/UserCleanUp.php#L63) | Kunden (`shopping_users.member_id`) → Sponsor (`shopping_user_member_logs`), Berater selbst wird `ShoppingUser` des Sponsors ([`setUserToClient()`](../../app/Services/UserUtil.php#L68)), Soft-Delete | **Kundenabos (`user_abos.member_id`) und Portal-Logins (`customers.member_id`) werden nie umgehängt.** Abo-Ausführungen nehmen `member_id` aus dem Abo ([`UserMakeOrder:278`](../../app/Cron/UserMakeOrder.php#L278)) – die Punkte landen dauerhaft beim gelöschten Berater. Keine Mail an den Sponsor. |
|
||||
|
||||
Dazu gibt es einen dritten, abweichenden Pfad: setzt ein Admin das Datum in die Vergangenheit ([`AdminUserController:155-175`](../../app/Http/Controllers/AdminUserController.php#L155)), läuft nur die Team-Stufe (`deactiveUserNewSponsorChilds()`), nie die Kunden.
|
||||
|
||||
**Fazit Ist:** Zwischen Tag +14 und +60 verschwinden Kundenpunkte im Nichts, Kundenabos fallen komplett durch das Raster, und 14 Tage lang wird ein unbezahlter Account voll verprovisioniert. Der Wunsch aus der Rückmeldung schließt genau diese Lücken.
|
||||
|
||||
#### Soll-Ablauf – Empfehlung: zwei Stufen behalten, Zeitpunkte und Umfang ändern
|
||||
|
||||
| Stufe | Zeitpunkt | Aktion |
|
||||
|---|---|---|
|
||||
| **1 „Pausieren“** | Ablauf + 2 Tage (Config `cleanup_suspend_days`) | wie heute `deactiveUser()` + Team umhängen (vorhanden, protokolliert, umkehrbar) **plus neu:** Kunden, Kundenabos und Portal-Logins **umkehrbar** umhängen (gleiches Log-Muster mit `pre_member_id`). Mail an den Berater (Stufe 35 umformulieren: „Dein Team und deine Kunden sind bis zum :date vorübergehend deinem Sponsor zugeordnet. Zahlst du bis dahin, bekommst du sie zurück.“). **Kurzinfo an den Sponsor, ohne Listen** (entschieden 14.09.): „Dein Teampartner :name hat seine Mitgliedschaft wegen ausstehender Zahlung nicht verlängert. Er hat noch bis zum :date (:days Tage) Zeit, sie wieder aufzunehmen, sonst wird die Mitgliedschaft dauerhaft entfernt. Bis dahin sind seine Kunden und Teampartner dir zugeordnet.“ |
|
||||
| **Rückgabe** | Zahlung bis Ablauf + 28 Tage (Config `cleanup_delete_days`) | bestehender Hook `reactiveUserResetChilds()` **plus neu:** Rückgabe der Kunden/Abos/Logins aus den Logs. Nur Datensätze, die noch beim Sponsor liegen (wurde ein Kunde zwischenzeitlich manuell woanders hin übertragen, bleibt er dort). |
|
||||
| **2 „Harter Cut“** | Ablauf + 28 Tage | Nichts mehr umhängen (ist schon passiert) → `setUserToClient()` + `deleteUser()` wie heute. **Neu:** Sponsor-Mail mit beiden Listen aus den Logs der Stufe 1 plus dem Berater selbst als neuem Kunden. Logs bleiben als Historie (Seiten „User Bereinigung“). |
|
||||
|
||||
**Warum „Provisionen laufen in der Zwischenzeit beim Sponsor“ von allein stimmt:** Umsätze werden **zum Bestellzeitpunkt** zugeordnet – [`SalesPointsVolume:164`](../../app/Services/BusinessPlan/SalesPointsVolume.php#L164) nimmt `shopping_orders.auth_user_id` bzw. `member_id` der Bestellung, Abo-Ausführungen `user_abos.member_id`. Sobald Kunde und Abo beim Sponsor hängen, landen alle Bestellungen der Pausenzeit auf dessen `user_sales_volumes` und bleiben dort auch nach der Rückgabe. Es muss nichts umgebucht werden; abgeschlossene Monate sind ohnehin eingefrorene Snapshots.
|
||||
|
||||
**Warum das die einfachste Variante ist**
|
||||
|
||||
- Alle Bausteine existieren: Deaktivieren, Team umhängen + zurückholen, Kunden umhängen, Berater-zu-Kunde, Soft-Delete, Zahlungs-Hook, Admin-Restore. Die Kundenübertragung wandert nur von Stufe 2 nach Stufe 1 und bekommt ihr Gegenstück „zurückholen“ (heute fehlt es, weil es nie nötig war).
|
||||
- Neu zu schreiben: (1) eine umkehrbare „Kunden umhängen / zurückholen“-Hilfe für `shopping_users`, `user_abos`, `customers` – analog zu `setNewSponsorToChilds()` / `resetChildsToSponsor()`, (2) eine Service-Klasse `ConsultantSuspensionService` mit `suspend()`, `reinstate()`, `terminate()`, die Cron, Zahlungs-Hook **und** Admin-Pfad gemeinsam nutzen (heute drei abweichende Pfade), (3) Mailable `SponsorTransferMail`, (4) Config + `--dry-run`, (5) Tests.
|
||||
- Keine neue Tabelle: `shopping_user_member_logs` hat bereits `pre_member_id`; für Abos und Portal-Logins entweder zwei kleine Log-Tabellen nach demselben Muster oder eine Spalte `type` in `shopping_user_member_logs`.
|
||||
- `ConsultantTransferService` (manuelle Übertragung, Ticket 08.09.) hängt Kunden, Abos und Portal-Logins bereits alle drei um – dieselbe Liste, gleiche Reihenfolge; die Hilfe lässt sich dort wiederverwenden.
|
||||
|
||||
**Umsetzung**
|
||||
|
||||
1. `config/mivita.php`: `cleanup_suspend_days = 2`, `cleanup_delete_days = 28` (beide Stellen in `UserCleanUp` sind heute hart codiert: `-2 weeks`, `-2 month`).
|
||||
2. `UserUtil`: `setCustomerDataToNewMember($from, $to)` (Kunden + Abos + Logins, protokolliert) und `resetCustomerDataToMember($userId)` (nur Datensätze, deren aktueller `member_id` noch dem im Log eingetragenen Ziel entspricht).
|
||||
3. `ConsultantSuspensionService`: `suspend(User)` = Stufe 1 in einer Transaktion; `reinstate(User)` = Rückgabe; `terminate(User)` = Stufe 2 + Mail. `UserCleanUp`, `Payment::paymentStatusPaidAction()`, `AdminUserController` (Datum setzen) und `AdminUserCleanupController::restore()` rufen nur noch diese drei Methoden.
|
||||
4. Zwei Mailables an den Sponsor (Queue): `SponsorSuspensionNoticeMail` bei Stufe 1 (Name des Beraters, Frist als Datum und Resttage, kein Listeninhalt) und `SponsorTransferMail` bei Stufe 2 (Blade-Tabellen mit Kunden und Teampartnern aus den Logs der Stufe 1 für genau diesen Berater plus der Berater selbst als neuer Kunde). Sponsor = Firma (User 6) → keine Mail.
|
||||
5. Texte: Stufe 35 (Berater), neue Sponsor-Mail, Dashboard-Banner `_reminder.blade.php`; de/en/es/fr.
|
||||
6. `user:cleanup --dry-run` listet beide Stufen mit Anzahl Kunden/Abos/Teampartner je Berater.
|
||||
7. Tests (`Carbon::setTestNow()`): Tag +1 nichts, Tag +2 Stufe 1 komplett (Team, Kunden, Abos, Logins, Baum-Ausschluss, Kurzinfo an Sponsor ohne Listen), Zahlung Tag +10 gibt alles zurück, manuell verschobener Kunde bleibt, Tag +28 harter Cut + Mail-Inhalt = Log-Inhalt, keine Mail an User 6, Admin-Pfad nutzt denselben Service.
|
||||
|
||||
**Risiken / Entscheidungen**
|
||||
|
||||
- **Einmaliger Massen-Effekt beim Deploy (entschieden 14.09.: akzeptiert):** Beim ersten Lauf werden alle Berater mit Ablauf > 2 Tagen sofort pausiert (heute 14) und alle > 28 Tagen gelöscht (heute 60); die Status werden dabei bewusst umgestellt. Ablauf: `--dry-run`-Liste ziehen und an Alois geben, dann der reguläre 03:30-Lauf. Beide Sponsor-Mails gehen in diesem ersten Lauf gesammelt raus – Sponsoren mit mehreren betroffenen Teampartnern bekommen mehrere Mails; falls unerwünscht, für den ersten Lauf eine Sammel-Mail je Sponsor vorsehen.
|
||||
- **Wofür die 2 Tage?** Nur Vorkasse/Überweisung braucht den Puffer; Karte/PayPal buchen sofort. Als Config einfach änderbar – 0 Tage wäre genauso möglich.
|
||||
- **Team-Statistik:** Pausierte Berater verschwinden ab Tag +2 aus dem Provisionsbaum und damit (nach C1) aus der Statistik – passt zur Provisionslogik, ist aber genau die Personengruppe der Jahresgebühr-Nachfassliste (`AnnualFeeDueService` bleibt davon unberührt, sie arbeitet über `payment_account`).
|
||||
- **Berater selbst als Kunde:** `setUserToClient()` legt immer einen neuen `ShoppingUser` an; hat der Berater bereits einen (`auth_user_id`), entsteht ein Duplikat – vorher per E-Mail prüfen (Bezug Ticket „Kunden löschen“ 08.09.).
|
||||
- **Admin-Restore nach dem harten Cut** (`user:restore`) holt heute nur das Team über die Logs zurück. Laut Kundin ist nach dem Cut eine Neuregistrierung mit Starterpaket der Regelfall; Restore bleibt Admin-Ausnahme und holt zurück, was die Logs noch hergeben – so dokumentieren.
|
||||
- Nebenbefunde: Kernel referenziert `UserCleanup::class`, Klasse heißt `UserCleanUp` (funktioniert nur durch case-insensitives Autoload); `AdminUserCleanupController::restore()` ruft `user:restore` zweimal auf (Zeilen 319/325).
|
||||
|
||||
**Aufwand:** 3–4 PT. **Entschieden (14.09.):** Sponsor bekommt bei Stufe 1 die Kurzinfo ohne Listen, bei Stufe 2 die Listen-Mail; der Status-Umbruch beim Deploy ist akzeptiert. **Offen (R3):** 2 Tage Versatz auch für Karten-/PayPal-Zahler oder dort sofort?
|
||||
|
||||
---
|
||||
|
||||
## 3. Statistik
|
||||
|
||||
### C1 – Team-Statistik und Team-Struktur zeigen dieselben Punkte
|
||||
|
||||
**Ziel (Protokoll):** Beispiel 30.756,50 (Statistik) vs. 30.281 (Struktur). Beide Seiten sollen die verprovisionierten Umsätze zeigen (Struktur = Referenz).
|
||||
|
||||
**Ist-Stand / Diagnose (sechs Ursachen, in Reihenfolge der Wirkung)**
|
||||
|
||||
| # | Ursache | Statistik ([`BackofficeDashboardService`](../../app/Services/Backoffice/BackofficeDashboardService.php)) | Struktur ([`TreeCalcBotOptimized`](../../app/Services/BusinessPlan/TreeCalcBotOptimized.php)) |
|
||||
|---|---|---|---|
|
||||
| 1 | Punkteart | rohe `user_sales_volumes.points` ohne `status_points`-Filter ([`SalesVolumePoints:43-47`](../../app/Services/Backoffice/SalesVolumePoints.php#L43)) → KP-only-Buchungen zählen mit | `month_TP_points + month_shop_points` ([`UserSalesVolume:239`](../../app/Models/UserSalesVolume.php#L239)), KP-only (`status_points = 2`) ausgeschlossen. Die Statistik kennt die Differenz schon: `nonPaylinePointsSum()` + Hinweis in `index.blade.php:264-272` |
|
||||
| 2 | Baumdefinition | jeder nicht gelöschte Downline-User, Tiefe 100, kein Kappen | nur `m_level` + `payment_account` gesetzt, `admin < 4`, `active_date <= Monatsende`, Tiefe 20, Unterbaum unter ausgefiltertem Knoten gekappt ([`readParentsBusinessUsers()`](../../app/Services/BusinessPlan/BusinessUserItemOptimized.php#L1068-L1092)) |
|
||||
| 3 | Rundung | 2 Dezimalen (`Util::formatNumber`) | `total_pp` cast `int` ([`UserBusiness:156`](../../app/Models/UserBusiness.php#L156)) |
|
||||
| 4 | Storno | negative Zeilen mindern die Summe | Knoten mit `TP_sum <= 0` werden übersprungen (`if ($points > 0)`, Zeilen 290/858) |
|
||||
| 5 | Live vs. Snapshot | laufender Monat live, geschlossene eingefroren | liest gespeicherte `UserBusinessStructure`, sobald eine existiert; hinkt dem Nachtlauf hinterher |
|
||||
| 6 | Aggregat vs. Zeilen | SUM über Zeilen | kumulative Monatsspalten (`reCalculateSalesPointsVolume`) – Drift bei verpasster Neuberechnung |
|
||||
|
||||
Ursache 1 und 2 erklären in der Regel den Großteil; für den konkreten Fall zuerst prüfen, ob `non_payline_points` des Users im Monat ≈ 475,50 ergibt.
|
||||
|
||||
Plan K11 aus `entwicklungsplan-2026-09-07.md` (3 Schritte) bleibt gültig; Vorarbeit existiert in [`PaylineForecastService::lineByUserId()`](../../app/Services/Abo/PaylineForecastService.php) (Provisionsbaum-Prädikat mit Regressionstest).
|
||||
|
||||
**Umsetzung**
|
||||
|
||||
1. **Messen:** Command `statistics:compare-trees {month} {year}` – für alle VIPs beide Definitionen nebeneinander (Berater, Neupartner, Abos, Punkte je Ebene, Summe). Ergebnis der Kundin zeigen, bevor etwas umgestellt wird.
|
||||
2. **Gemeinsames Prädikat** aus `PaylineForecastService` in einen `CommissionTreeDefinition`-Service heben; `lineBuckets()` bekommt die Provisionsbaum-Variante; Punkte-Metrik der Statistik auf TP (`status_points != 2`) umstellen.
|
||||
3. Umschalter „inkl. abgelaufene Berater“ (Empfehlung aus F4), damit die Jahresgebühr-Nachfassliste nicht verschwindet.
|
||||
4. Struktur: Dezimalen anzeigen (Cast/Formatierung, nicht die Speicherung), Snapshot-Alter anzeigen bzw. „live“ für den laufenden Monat.
|
||||
5. `BackofficeStatisticsSnapshot` für geschlossene Monate neu rechnen, `cache:clear` nach Livegang (6-h-Caches ohne Invalidierung).
|
||||
6. Test: `StatisticsConsistencyTest` um „Statistik-Summe == Struktur-Summe für denselben Baum“ erweitern.
|
||||
|
||||
**Umsetzungsstand (14.09.2026) — umgesetzt, Tests grün (125 Tests der Statistik-Suite)**
|
||||
|
||||
| Baustein | Datei |
|
||||
|---|---|
|
||||
| Provisionspunkte-Definition (TP + Shop, ohne Nur-KP, Knotenregel je User) | `app/Services/Backoffice/SalesVolumePoints.php` (`commissionPoints()`, `commissionPointsByUser()`, `floorPerUser()`) |
|
||||
| Provisionsbaum als zweite Baum-Definition + Umschalter, gespeicherte Struktur für abgeschlossene Monate | `app/Services/Backoffice/BackofficeDashboardService.php` (`TREE_COMMISSION`/`TREE_DOWNLINE`, `commissionLineBuckets()`, `storedCommissionLineBuckets()`, `countsInCommissionTree()`, `liveOverview()`) |
|
||||
| Detaillisten folgen der Baum-Definition; Gesamtpunkte je Berater = Provisionspunkte | `app/Services/Backoffice/BackofficeDrilldownService.php` |
|
||||
| Payline-Prognose nutzt dasselbe Prädikat (kein Duplikat mehr) | `app/Services/Abo/PaylineForecastService.php` |
|
||||
| Snapshot je Baum-Definition (Spalte `tree`, Altbestand = `downline`) | Migration `2026_09_14_140000_add_tree_to_backoffice_statistics_snapshots_table`, `BackofficeStatisticsSnapshot`, `backoffice:store-statistics-snapshots` (speichert beide Varianten, `--tree=`) |
|
||||
| Umschalter „Provisionsbaum (wie Team-Struktur)“ / „Gesamte Downline“, Hinweistexte, Session-Merker | `BackofficeStatisticsController`, `resources/views/user/backoffice/statistics/{index,details}.blade.php` |
|
||||
| Struktur-Detail: „Gesamt“ = Summe der Linien mit Dezimalen (statt `total_pp` als int) | `resources/views/admin/business_optimized/_user_detail_in.blade.php` |
|
||||
| Mess-Command | `php artisan statistics:compare-trees {monat} {jahr} [--user=ID] [--lines] [--live-structure] [--repair-running-totals]` |
|
||||
| Tests | `tests/Feature/StatisticsCommissionTreeTest.php` (neu), Anpassungen in `StatisticsConsistencyTest`, `BackofficeStatisticsListsTest`, `BackofficeStatisticsDrilldownTest`, `BackofficeStatisticsAccessTest`, `tests/Unit/Services/BackofficeDashboardServiceTest` |
|
||||
|
||||
**Ergebnis des Messlaufs (Dev-DB, 14.09.):**
|
||||
|
||||
- Die Punkte-Differenz DL vs. PB ist bei allen sieben VIPs 0 – die ausgefilterten Berater (49–297 je VIP) haben keinen Umsatz. Die Ursache der Kundinnen-Beobachtung liegt also in der Punkteart: Nur-KP-Buchungen (600–1.690 Punkte je VIP im September, 4.800–9.090 im August) waren in der Statistik enthalten, in der Struktur nicht. Diese Punkte werden jetzt unter der Tabelle gesondert ausgewiesen.
|
||||
- September (beide live): Statistik PB = Struktur bis auf 216 Punkte (mivita/Claudia/Go4) bzw. 129 (Rosa). Das ist exakt die **Laufstand-Drift** zweier User (848: −129, 1543: −87): zwei Abo-Ausführungen liefen fast zeitgleich (04:04:19 / 04:04:35), die kumulativen Spalten `month_shop_points` haben sich überholt, die letzte Zeile ist zu niedrig. **Der TreeCalcBot liest genau diese Spalte – der Struktur und damit der Provision fehlen die Punkte.** Die Statistik summiert die Einzelzeilen und ist richtig. `--repair-running-totals` rechnet die Laufstände mit `SalesPointsVolume::reCalculateSalesPointsVolume()` neu. Im laufenden Monat rechnet die Struktur live aus diesen Spalten – nach der Reparatur stimmt sie sofort (nur `cache:clear` für die Provisions-Kachel). **Nicht** `business:store-optimized` für den Monat starten: das ist der Monatsabschluss (Gutschriften, Level-Update, Bonuspunkte), läuft per Tages-Guard nur am 1. für den Vormonat und würde mit `--clear` doppelte Gutschriften erzeugen.
|
||||
- August (Struktur = Nachtlauf 01.09.): zunächst 245,50 Differenz = 82 Drift (User 1210) + 163,50 einer Beraterin, die am 01.09. deaktiviert war und am 08.09. reaktiviert wurde. Deshalb liest die Statistik für **abgeschlossene** Monate die Mitglieder aus dem gespeicherten Provisionslauf (`user_businesses`/`user_business_structures`) statt aus dem heutigen Baum → Restdifferenz nur noch die 82 Drift-Punkte.
|
||||
- Storno-Knotenregel (R4) wurde **nicht** in der Provisionsberechnung angefasst; die Statistik spiegelt sie und weist den nicht abgezogenen Negativsaldo aus.
|
||||
|
||||
**Deploy-Schritte** — erledigt am 14.09. (Verlauf und Ergebnis: [deploy-2026-09-14.md](../2026-07-22/deploy-2026-09-14.md))
|
||||
|
||||
1. `php artisan migrate` (markiert bestehende Snapshots als `downline`).
|
||||
2. `php artisan backoffice:store-statistics-snapshots --force` (beide Baum-Definitionen mit neuer Punkte-Definition für alle abgeschlossenen Monate; Provisionsbaum-Variante kommt aus dem jeweils gespeicherten Provisionslauf).
|
||||
3. `php artisan sales:repair-running-totals` (repariert die Laufstände im laufenden Monat; Struktur ist danach sofort richtig). **August nicht anfassen** (Entscheid 14.09.: keine Nachzahlung, keine Nachberechnung) – die Laufstände des August bleiben so, wie sie abgerechnet wurden; der Fix gilt ab September. Zur Kontrolle vorab: `php artisan statistics:compare-trees 9 2026`.
|
||||
4. `php artisan cache:clear` (6-h-Caches der Prognosen).
|
||||
5. Kundenmitteilung: „Umsatz (Punkte)“ zeigt ab jetzt die Provisionspunkte; Nur-KP-Punkte stehen als eigene Zahl darunter; Umschalter „Gesamte Downline“ für die bisherige Sicht.
|
||||
|
||||
**Offener Folgepunkt (neu, wichtig) → eigenes Ticket C2, siehe unten.**
|
||||
|
||||
**Machbarkeit / Risiken (hoch, aber beherrschbar)**
|
||||
|
||||
- `lineBuckets()` ist die Basis **aller** Kacheln (Home-Dashboard, Neupartner, Abos, Top 3, Führungskräfte) – jede Zahl ändert sich, auch rückwirkend in der Anzeige. Nur mit Vorab-Messung und Kundenmitteilung ausrollen.
|
||||
- **Ursache 4 nicht nebenbei „fixen“:** Das `> 0`-Guard sitzt in der Provisionsberechnung. Es zu ändern, verändert Auszahlungen (Stornos der Downline würden erstmals Provision mindern). Das ist eine eigene fachliche Entscheidung (**Rückfrage R4**), nicht Teil der Angleichung. Bis dahin gleicht sich die Statistik der Struktur an, nicht umgekehrt.
|
||||
- Offene Frage F4 (abgelaufene Berater) muss vorher beantwortet sein.
|
||||
|
||||
### C2 – Laufstand-Race in `user_sales_volumes` (Provision zu niedrig) — Stufe 1+2 live seit 14.09., Stufe 3 offen
|
||||
|
||||
**Befund (14.09.):** Jede Buchung schreibt eine Zeile mit ihren eigenen Punkten (`points`) **und** dem kumulierten Monatsstand (`month_TP_points`, `month_shop_points`, `month_KP_points`), den [`SalesPointsVolume::reCalculateSalesPointsVolume()`](../../app/Services/BusinessPlan/SalesPointsVolume.php#L77) nach jedem Insert über alle Zeilen des Users neu rechnet. Die Provisionsberechnung liest **nur den Laufstand der letzten Zeile** ([`UserSalesVolume::getPointsTPSum()`](../../app/Models/UserSalesVolume.php#L239) über `User::getUserSalesVolumeBy()`).
|
||||
|
||||
Die Zeilen entstehen in [`Payment::paymentStatusPaidAction()`](../../app/Services/Payment.php#L170), aufgerufen aus den Zahlungs-Webhooks ([`PayoneWebhookHandler:189`](../../app/Services/Payment/Payone/PayoneWebhookHandler.php#L189), [`StripeWebhookHandler:363`](../../app/Services/Payment/Stripe/StripeWebhookHandler.php#L363)) — jeder Webhook ist ein eigener Web-Request in einer eigenen Transaktion (Lock nur auf der Bestellung, nicht auf dem User). Laufen für **denselben Berater** zwei Abo-Ausführungen fast zeitgleich (04:00-Cron, zwei Kundenabos), sieht jede Transaktion unter `REPEATABLE READ` nur ihren eigenen Snapshot: Zeile B wird ohne die noch nicht committete Zeile A summiert. Die höhere ID (B) ist dann die „letzte Zeile“ mit einem zu niedrigen Laufstand.
|
||||
|
||||
**Es geht keine Buchung verloren** – alle `points` sind korrekt, die Statistik (Zeilensumme) stimmt. Falsch ist der Laufstand, den die Provision liest. Der Fehler heilt sich nur, wenn derselbe User im selben Monat später noch eine Buchung bekommt (dann wird alles neu summiert). Ist die betroffene Zeile die letzte des Monats, geht der Monatsabschluss mit dem falschen Wert in die Provision.
|
||||
|
||||
**Ausmaß (Dev-DB, alle Monate seit 2025):** 06/2026: 1 User / 66 Punkte · 08/2026: 1 User / 82 Punkte (abgerechnet!) · 09/2026: 2 User / 216 Punkte (offen). Nachweis: `php artisan statistics:compare-trees {monat} {jahr}` (Abschnitt „Laufstand-Drift“).
|
||||
|
||||
**Entscheid (14.09.):** Keine Nachzahlung und keine Nachberechnung für Juni/August. Der Fix gilt ab September 2026; abgerechnete Monate bleiben unverändert.
|
||||
|
||||
**Umsetzung (Stufe 1 + 2 umgesetzt, Stufe 3 offen)**
|
||||
|
||||
| Stufe | Was | Stand |
|
||||
|---|---|---|
|
||||
| 1 Sicherheitsnetz | Neues Kommando [`sales:repair-running-totals`](../../app/Console/Commands/SalesRepairRunningTotals.php) täglich **02:45** im Scheduler ([`Kernel.php`](../../app/Console/Kernel.php)), also vor dem Provisionslauf um 03:00. Prüft alle User mit Buchungen im laufenden Monat und im Vormonat (Vormonat nur, solange er noch nicht abgerechnet ist – kein Anfassen abgeschlossener Monate). Abweichende User werden neu gerechnet, Ergebnis geht ins `cron`-Log. Manuell: `{monat} {jahr}`, `--dry-run`; ein abgerechneter Monat wird ohne `--force` verweigert. Logik im Service [`SalesVolumeRunningTotalService`](../../app/Services/BusinessPlan/SalesVolumeRunningTotalService.php), den auch `statistics:compare-trees` nutzt. | umgesetzt |
|
||||
| 2 Ursache | [`SalesPointsVolume::runningTotalRows()`](../../app/Services/BusinessPlan/SalesPointsVolume.php) liest die Zeilen des Beraters jetzt sperrend (`SELECT … FOR UPDATE`): ein sperrendes Lesen liefert immer den zuletzt committeten Stand und wartet auf die parallele Insert-Transaktion – die zweite Buchung sieht die erste. Neuer Index (`user_id`, `year`, `month`) (Migration `2026_09_14_160000`), damit nur der Monatsbereich des Beraters gesperrt wird. Beide Webhook-Handler (Payone, Stripe) laufen den Paid-Pfad über `DB::transaction($fn, 3)`: bei Deadlock/Lock-Timeout wird die ganze Transaktion wiederholt. In `Payment::paymentStatusPaidAction()` werden Deadlock/Lock-Timeout aus der Rechnungserstellung **nicht mehr geschluckt** (vorher hätte der try/catch dort eine bereits zurückgerollte Transaktion als „bezahlt“ bestätigt) – neuer Helfer [`ConcurrencyError`](../../app/Services/Database/ConcurrencyError.php). | umgesetzt |
|
||||
| 3 Strukturell | Provision aus der Zeilensumme rechnen statt aus dem Laufstand der letzten Zeile (`getPointsTPSum()` als `SUM(...)` je User/Monat) – Teil des Ledger-Plans `dev/2026-09-10-business-plan/`. Dann sind die `month_*`-Spalten nur noch informativ. | offen |
|
||||
|
||||
**Grenzen des Tests:** Die Pest-Suite läuft auf SQLite, das kein `FOR UPDATE` kennt; der Race selbst ist dort nicht reproduzierbar. Getestet sind Erkennung, Reparatur, Monatsauswahl des Nachtlaufs, die Sperr-Flagge der Abfrage und die Deadlock-Erkennung ([`SalesVolumeRunningTotalTest`](../../tests/Feature/SalesVolumeRunningTotalTest.php)). Auf MySQL wirkt die Sperre über den Index; Nachweis im Betrieb: `sales:repair-running-totals --dry-run` meldet über mehrere Nächte „keine Abweichung“.
|
||||
|
||||
**Nebenwirkung Retry:** Ein Retry wiederholt den ganzen Paid-Pfad. Alles darin ist Datenbank-Arbeit bis auf das Dispatch des Stripe-Rechnungsreferenz-Jobs; der könnte im seltenen Deadlock-Fall doppelt in die Queue gehen (idempotent, setzt nur die Referenz).
|
||||
|
||||
**Deploy-Schritte C2** (erledigt 14.09.): `php artisan migrate` (Index) · `php artisan sales:repair-running-totals` (September einmalig reparieren; danach übernimmt der Nachtlauf) · `php artisan cache:clear` (Provisions-Kachel).
|
||||
|
||||
---
|
||||
|
||||
## 4. Backoffice-Inhalte
|
||||
|
||||
### D1 – Neuer Reiter „Events“ mit Foto-Galerien
|
||||
|
||||
**Ziel (Protokoll):** Reiter ähnlich „News Archiv“, in dem Susi regelmäßig Fotos von Veranstaltungen und Calls hochlädt (Jahresevent, Firmencalls). Fotos liegen Kevin bereits vor.
|
||||
|
||||
**Ist-Stand**
|
||||
|
||||
- News: [`DashboardNews`](../../app/Models/DashboardNews.php) (`title, teaser, content, trans_*, file_links, active, display_date`), Admin-CRUD in [`SitesController:24-113`](../../app/Http/Controllers/SitesController.php#L24), User-Seite [`HomeController::newsArchive()`](../../app/Http/Controllers/HomeController.php#L42), Routen `crm.php:79` und `:270-274`, Sidenav `:51-57` (User) und `:548-559` (Admin). **Keine Bild-Spalte, keine Galerie** – nur Links ins Download-Center.
|
||||
- Galerie-Muster vorhanden: [`ProductController::imageUpload/imageDelete`](../../app/Http/Controllers/ProductController.php#L151-L215) mit Slim-Cropper, `ProductImage` (`pos`, `active`), `Storage::disk('public')`, View `admin/product/images.blade.php`.
|
||||
|
||||
**Umsetzung**
|
||||
|
||||
1. Migrationen `events` (`title, teaser, content, event_date, active, sort`, optional `trans_*` wie News) und `event_images` (`event_id, filename, original_name, ext, mime, size, pos, active`).
|
||||
2. Admin-CRUD `Admin\EventController` nach dem News-Muster + Bild-Upload nach dem Produkt-Muster (Mehrfach-Upload nacheinander per JS, serverseitig auf max. 1920 px verkleinern + Thumbnail 400 px über GD/Imagick, beides installiert). Sortierung per Drag & Drop wie Produktbilder.
|
||||
3. User-Seite `/events` (Liste nach Datum, Kachel mit Titelbild, Detail mit Galerie und Lightbox-Modal), Sidenav neben „News Archiv“, Berechtigung wie News (Admin-Rolle `admin >= 2`; Susi braucht diese Rolle).
|
||||
4. Routen im `admin`-Block von `crm.php`, `navigation.php` de/en/es/fr.
|
||||
5. Tests: CRUD-Feature-Test, Upload-Test mit `Storage::fake('public')`, Zugriff nur für aktive Accounts.
|
||||
|
||||
**Risiken:** niedrig. Speicherplatz und Upload-Limits (`upload_max_filesize`/`post_max_size` prüfen) → Einzel-Uploads statt eines Multipart-Requests mit 50 Fotos.
|
||||
|
||||
### D2 – Top 3 firmenweit im Backoffice
|
||||
|
||||
**Ziel (Protokoll):** Nach Klärung mit dem Network-Anwalt die Top 3 jeden Monat firmenweit anzeigen; bis dahin nur teamintern (Ist-Zustand).
|
||||
|
||||
**Ist-Stand:** [`TopResultsService`](../../app/Services/Team/TopResultsService.php) (`SCOPE = 'team'`, Kommentar beschreibt den Firmen-Umbau über `scopedUserIds()`). Offene Punkte aus K10: Cache-Key ohne User, „ich“-Markierung nach dem Cache setzen, Einwilligungstext Profilfoto (Platzhalter).
|
||||
|
||||
**Empfehlung:** Nichts bauen, bis die schriftliche Freigabe (F3) vorliegt. Umsetzung dann 0,5 PT plus Einwilligungstext.
|
||||
|
||||
---
|
||||
|
||||
## 5. Aktionen
|
||||
|
||||
### E1 – Recruiting-Challenge Oktober–Januar
|
||||
|
||||
**Ziel (Protokoll):** Pro Neupartner mit Paket 50 € im Qualifikationszeitraum, Top 3 erhalten zusätzlich einen Bonus, Auszahlung gesammelt im Februar, wieder ein Ranking. Details klären Dani und Alois.
|
||||
|
||||
**Ist-Stand:** Incentive-Modul passt fast vollständig: [`Incentive`](../../app/Models/Incentive.php) (`qualification_start/end`, `calculation_end`, `points_partner_onetime`, `max_winners`), [`IncentiveTracker::trackNewPartner()`](../../app/Services/Incentive/IncentiveTracker.php#L24-L66) feuert exakt bei „bezahlte Registrierung mit Starterpaket“ ([`ShoppingOrder::qualifiesForIncentiveTrackedPartner()`](../../app/Models/ShoppingOrder.php#L443)), Ranking, Teilnahme, Detailansicht und Admin-CRUD vorhanden. `IncentiveParticipant::rebuildFromSourceTables()` erlaubt nachträgliches Aufsetzen – **Start am 01.10. ist also kein harter Termin für die Entwicklung.**
|
||||
|
||||
**Umsetzung**
|
||||
|
||||
1. **Nur Konfiguration (0,5 PT):** Incentive anlegen (01.10.2026–31.01.2027, `calculation_end` 28.02.2027, `points_partner_onetime = 1`, `points_abo_onetime = 0`, `min_direct_partners = 0`, `min_customer_abos = 0`, `max_winners = 3`), Texte/Bild.
|
||||
2. **Erweiterung (1–2 PT):** Felder `amount_per_partner` (50 €) und `bonus_rank_1/2/3`; Auszahlungs-Export (XLSX, Muster Newsletter-Export) für die Februar-Auszahlung bzw. Gutschrift über `Credit`; Sidenav-Verlinkung für **mehrere parallele** aktive Incentives ([`HomeController:38-41`](../../app/Http/Controllers/HomeController.php#L38) nimmt heute nur `first()` – Montenegro läuft bis Anfang Oktober parallel).
|
||||
3. Sobald D2 freigegeben ist, dieselbe Rangliste firmenweit.
|
||||
|
||||
**Risiko:** niedrig. Offene Details (Bonushöhe Top 3, Anrechnung von Stornos/Rücktritten innerhalb des Zeitraums – `IncentivePointsLog.is_storno` existiert) mit Dani/Alois klären.
|
||||
|
||||
---
|
||||
|
||||
## 6. Rückfragen an die Kundin / Alois (vor Start des jeweiligen Pakets)
|
||||
|
||||
| # | Frage | Betrifft |
|
||||
|---|---|---|
|
||||
| **R1** | Gewünschter Tag im laufenden Monat schon vorbei (heute 12., Wunsch 5.): Fehlermeldung, oder automatisch für den Folgemonat übernehmen? | A1 |
|
||||
#Einstellungen können immer nur im aktuellen Monat vorgenommen werden, bis die Ausführung erfolgt ist. Nach erfolgreicher Ausführung kann der Termin im Folgemonat festgelegt werden.
|
||||
| **R2** | Gilt „keine Sperre mehr“ auch für Pausieren/Kündigen (heute 3 Tage vorher)? Empfehlung: ja, bis 23:59 Uhr des Vortags. | A2 |
|
||||
#ja
|
||||
| **R3** | Berater-Ausfall: 2 Tage Versatz auch bei Karten-/PayPal-Zahlern, oder dort sofort pausieren? (Sponsor-Kurzinfo bei Stufe 1 und Deploy-Effekt sind am 14.09. entschieden.) | B2 |
|
||||
#auch zwei tages versatz
|
||||
| **R4** | Sollen Stornos der Downline die Team-Punkte (und damit Provisionen) mindern? Heute werden negative Monate einzelner Partner im Provisionsbaum ignoriert. | C1 |
|
||||
#müsste noch einmal genau geprüft werden. Eigentlich sollten Stornos immer für den aktuellen Monat abgezogen werden. Würde damit auch Team Punkte mindern, da die Punkte ja aus der Summierung rausfallen. Ist der Monat abgeschlossen, werden die Zahlen ja fest ermittelt und auch Provisionen erstellt. Dann dürfen stornos nicht mehr rückwirkend die Statistik oder punkte verändern.
|
||||
| **R5** | Kundenabo → Partnerabo: Freigabe der Kompensationsprodukte durch wen? Sponsor automatisch der bisherige Berater? Zeitpunkt der Umstellung? | A5 |
|
||||
#das macht der neue Partner selber wenn er sich wieder einloggt hat er die Möglichkeit sein Abo zu Transformieren d.h. zu einem Berater Abo mit entsprechenden Preisen und Kompensationsprodukt was er auswählen muss. Das kann nur er machen. Er muss es auch akzeptieren und bestätigen.
|
||||
| **R6** | Auto-Abbuchung: wie viele Tage vor Ablauf, welcher Zahlungsanbieter, auch Reaktivierung bereits deaktivierter Berater? | B1 |
|
||||
STRIPE Wie Abo fünf Tage vorher wird versucht zu buchen. Das muss auch kommuniziert werden über die Mails Hinweise etc.
|
||||
Wäre auch eine Ankündigung, wenn eine automatische Buchung kommt zwei Tage vorher, Reaktivierung ist nicht möglich, weil das über sepa lief.
|
||||
| **F3/F4 (alt)** | Anwaltsfreigabe Top 3 firmenweit; Statistik mit/ohne abgelaufene Berater. | D2, C1 |
|
||||
#Hier warten wir auf Freigabe
|
||||
|
||||
---
|
||||
|
||||
## 7. Querschnitt
|
||||
|
||||
- **Sprachdateien:** jede Textänderung in de/en/es/fr (`LocalizationSyncTest`).
|
||||
- **Tests:** Suite läuft auf SQLite in-memory; Stand 14.09.: 92 vorbestehende Fehlschläge (siehe `2026-09-08.md`), neue Tests müssen isoliert grün sein. Admin-Routen-Tests brauchen den CRM-Host-Trick aus `ConsultantTransferTest`.
|
||||
- **Settings/Deploy:** A2 setzt `abo-lock-days-change` (und ggf. `-pause-cancel`) auf 0; C1 braucht `cache:clear` und Snapshot-Neuberechnung; B2 braucht `--dry-run`-Liste vor dem ersten Lauf.
|
||||
- **Pint** nach jeder Änderung.
|
||||
|
|
@ -267,7 +267,14 @@
|
|||
<tfoot>
|
||||
<tr>
|
||||
<td>{{__('tables.total')}}</td>
|
||||
<td>{{ formatNumber($TreeCalcBot->__get('business_user')->total_pp) }}</td>
|
||||
{{-- Summe der angezeigten Linien statt total_pp: die Spalte ist als int gespeichert
|
||||
und verliert die Dezimalen — die Team-Statistik zeigt dieselben Punkte mit
|
||||
zwei Nachkommastellen, beide Seiten sollen sich auf den Cent decken. --}}
|
||||
@php
|
||||
$structureLineTotal = collect($TreeCalcBot->__get('business_user')->business_lines?->toArray() ?? [])
|
||||
->sum(fn ($lineObject) => (float) (is_array($lineObject) ? ($lineObject['points'] ?? 0) : ($lineObject->points ?? 0)));
|
||||
@endphp
|
||||
<td>{{ formatNumber($structureLineTotal) }}</td>
|
||||
<td></td>
|
||||
<td><strong>{{ formatNumber($TreeCalcBot->__get('business_user')->commission_pp_total, 2) }} €</strong></td>
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
@extends('layouts.layout-2')
|
||||
|
||||
@section('content')
|
||||
@php
|
||||
// Baum-Definition der aufrufenden Uebersicht; Fallback fuer direkte Aufrufe ohne Controller-Variable.
|
||||
$selectedTree = $selectedTree ?? ($details['tree'] ?? \App\Services\Backoffice\BackofficeDashboardService::TREE_COMMISSION);
|
||||
@endphp
|
||||
<h4 class="font-weight-bold py-2 mb-2 d-flex justify-content-between align-items-center w-100">
|
||||
<div>
|
||||
{{ __('navigation.statistics') }} / {{ $details['metric_label'] }}
|
||||
|
|
@ -15,12 +19,13 @@
|
|||
'metric' => $details['metric'],
|
||||
'month' => $selectedMonth,
|
||||
'year' => $selectedYear,
|
||||
'tree' => $selectedTree,
|
||||
'only_new' => ($details['only_new'] ?? false) ? 1 : 0,
|
||||
]) }}"
|
||||
class="btn btn-sm btn-outline-secondary mr-2">
|
||||
<span class="ion ion-md-download mr-1"></span> CSV
|
||||
</a>
|
||||
<a href="{{ route('user_backoffice_statistics', ['month' => $selectedMonth, 'year' => $selectedYear]) }}"
|
||||
<a href="{{ route('user_backoffice_statistics', ['month' => $selectedMonth, 'year' => $selectedYear, 'tree' => $selectedTree]) }}"
|
||||
class="btn btn-sm btn-default">
|
||||
{{ __('back') }}
|
||||
</a>
|
||||
|
|
@ -46,6 +51,10 @@
|
|||
<div class="text-muted small">Treffer</div>
|
||||
<strong>{{ number_format($details['summary']['count'], 0, ',', '.') }}</strong>
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<div class="text-muted small">Baum-Definition</div>
|
||||
<span class="badge badge-outline-primary">{{ $details['tree_label'] ?? '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -167,7 +176,12 @@
|
|||
<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>
|
||||
<td class="text-right font-weight-bold" data-sort-value="{{ $row['total_points'] }}">
|
||||
{{ \App\Services\Util::formatNumber($row['total_points']) }}
|
||||
@if (! empty($row['storno_floor_points']))
|
||||
<div class="text-muted small font-weight-normal" title="Negativer Monatssaldo — die Provisionsberechnung wertet ihn mit 0.">Saldo −{{ \App\Services\Util::formatNumber($row['storno_floor_points']) }} → 0</div>
|
||||
@endif
|
||||
</td>
|
||||
@elseif ($details['metric'] === 'new_partners')
|
||||
<td>{{ ($row['phone'] ?? '') !== '' ? $row['phone'] : '-' }}</td>
|
||||
<td>{{ $row['sponsor'] ?? '-' }}</td>
|
||||
|
|
|
|||
|
|
@ -13,10 +13,27 @@
|
|||
<div class="row align-items-center">
|
||||
<div class="col-md-8">
|
||||
<h5 class="mb-2">{{ __('navigation.statistics') }} / Backoffice MVP</h5>
|
||||
<p class="text-muted mb-md-0">
|
||||
<p class="text-muted mb-1">
|
||||
Linienbasierte Übersicht für alle vorhandenen Team-Linien. Klickbare Zahlen führen direkt in die
|
||||
passenden Namen- und Detaillisten.
|
||||
</p>
|
||||
<p class="text-muted small mb-md-0">
|
||||
Baum-Definition:
|
||||
<span class="badge badge-outline-primary">{{ $treeLabels[$selectedTree] ?? $selectedTree }}</span>
|
||||
@if ($selectedTree === \App\Services\Backoffice\BackofficeDashboardService::TREE_COMMISSION)
|
||||
— dieselben Mitglieder und dieselben Punkte (Provisionspunkte) wie die
|
||||
<a href="{{ route('user_team_structure') }}">Team-Struktur</a>.
|
||||
@if (($statistics['tree_source'] ?? 'live') === 'stored_structure')
|
||||
Mitglieder je Linie stammen aus dem gespeicherten Provisionslauf dieses Monats.
|
||||
@else
|
||||
Im laufenden Monat rechnen beide Seiten live; die Provisions-Kachel ist bis zu 6 Stunden
|
||||
zwischengespeichert.
|
||||
@endif
|
||||
@else
|
||||
— jeder nicht gelöschte Teampartner zählt, auch ohne Mitgliedsebene oder Abrechnungskonto und
|
||||
unterhalb solcher Partner. Diese Sicht weicht bewusst von der Team-Struktur ab.
|
||||
@endif
|
||||
</p>
|
||||
@if (!empty($performance))
|
||||
<div class="small text-muted mt-2">
|
||||
Datenquelle:
|
||||
|
|
@ -35,10 +52,17 @@
|
|||
<div class="col-md-4 mt-3 mt-md-0">
|
||||
<form method="GET" action="{{ route('user_backoffice_statistics') }}"
|
||||
class="form-inline justify-content-md-end">
|
||||
<a href="{{ route('user_backoffice_statistics_overview_export', ['month' => $selectedMonth, 'year' => $selectedYear]) }}"
|
||||
<a href="{{ route('user_backoffice_statistics_overview_export', ['month' => $selectedMonth, 'year' => $selectedYear, 'tree' => $selectedTree]) }}"
|
||||
class="btn btn-sm btn-outline-secondary mr-2">
|
||||
<span class="ion ion-md-download mr-1"></span> CSV
|
||||
</a>
|
||||
<select name="tree" class="form-control custom-select form-control-sm mr-2" title="Baum-Definition">
|
||||
@foreach ($treeLabels as $treeKey => $treeLabel)
|
||||
<option value="{{ $treeKey }}" {{ $selectedTree === $treeKey ? 'selected' : '' }}>
|
||||
{{ $treeLabel }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<select name="month" class="form-control custom-select form-control-sm mr-2">
|
||||
@foreach ($filterMonths as $monthNumber => $monthName)
|
||||
<option value="{{ $monthNumber }}"
|
||||
|
|
@ -98,6 +122,7 @@
|
|||
'metric' => 'new_partners',
|
||||
'month' => $selectedMonth,
|
||||
'year' => $selectedYear,
|
||||
'tree' => $selectedTree,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -106,6 +131,7 @@
|
|||
'metric' => $metric,
|
||||
'month' => $selectedMonth,
|
||||
'year' => $selectedYear,
|
||||
'tree' => $selectedTree,
|
||||
'only_new' => 1,
|
||||
]);
|
||||
};
|
||||
|
|
@ -120,6 +146,7 @@
|
|||
'metric' => 'new_partners',
|
||||
'month' => $selectedMonth,
|
||||
'year' => $selectedYear,
|
||||
'tree' => $selectedTree,
|
||||
]) }}"
|
||||
class="card h-100 text-decoration-none backoffice-kpi-card">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
|
|
@ -142,6 +169,7 @@
|
|||
'metric' => 'shop_1000',
|
||||
'month' => $selectedMonth,
|
||||
'year' => $selectedYear,
|
||||
'tree' => $selectedTree,
|
||||
]) }}"
|
||||
class="card h-100 text-decoration-none backoffice-kpi-card">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
|
|
@ -211,6 +239,7 @@
|
|||
'metric' => $metric,
|
||||
'month' => $selectedMonth,
|
||||
'year' => $selectedYear,
|
||||
'tree' => $selectedTree,
|
||||
]) }}"
|
||||
class="badge badge-pill badge-outline-default backoffice-statistics-click-badge">
|
||||
{{ $formatValue($line[$metric], $metric) }}
|
||||
|
|
@ -241,6 +270,7 @@
|
|||
'metric' => $metric,
|
||||
'month' => $selectedMonth,
|
||||
'year' => $selectedYear,
|
||||
'tree' => $selectedTree,
|
||||
]) }}"
|
||||
class="badge badge-pill badge-secondary backoffice-statistics-click-badge">
|
||||
{{ $formatValue($statistics['totals'][$metric], $metric) }}
|
||||
|
|
@ -261,17 +291,36 @@
|
|||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
@php $nonPaylinePoints = $statistics['non_payline_points'] ?? null; @endphp
|
||||
@if ($nonPaylinePoints !== null && $nonPaylinePoints > 0)
|
||||
<div class="card-body py-2 border-top">
|
||||
<p class="text-muted small mb-0">
|
||||
In den Gesamtpunkten enthalten:
|
||||
<strong>{{ \App\Services\Util::formatNumber($nonPaylinePoints) }} Punkte</strong>,
|
||||
die nicht in die Payline fließen (z. B. Neupartner-Einschreibungspunkte) —
|
||||
die Punkte-Summe der Struktur-/Provisionsansicht fällt deshalb um diesen Betrag niedriger aus.
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
@php
|
||||
$nonPaylinePoints = $statistics['non_payline_points'] ?? null;
|
||||
$stornoFloorPoints = $statistics['storno_floor_points'] ?? null;
|
||||
$isLegacyPayload = (int) ($statistics['payload_version'] ?? 1) < 3;
|
||||
@endphp
|
||||
<div class="card-body py-2 border-top">
|
||||
<p class="text-muted small mb-0">
|
||||
@if ($isLegacyPayload)
|
||||
<strong>Umsatz (Punkte)</strong> = Eigen- + Kundenpunkte (ältere Berechnung dieses Monats,
|
||||
inkl. Punkte ohne Payline-Wirkung).
|
||||
@if ($nonPaylinePoints !== null && $nonPaylinePoints > 0)
|
||||
Davon <strong>{{ \App\Services\Util::formatNumber($nonPaylinePoints) }} Punkte</strong> ohne
|
||||
Payline-Wirkung — die Team-Struktur fällt um diesen Betrag niedriger aus.
|
||||
@endif
|
||||
@else
|
||||
<strong>Umsatz (Punkte)</strong> = Provisionspunkte (Teampunkte + Kundenpunkte), so wie sie in
|
||||
der Team-Struktur je Linie stehen und verprovisioniert werden.
|
||||
@if ($nonPaylinePoints !== null && $nonPaylinePoints > 0)
|
||||
Nicht enthalten: <strong>{{ \App\Services\Util::formatNumber($nonPaylinePoints) }} Punkte</strong>
|
||||
ohne Payline-Wirkung (z. B. Neupartner-Einschreibungspunkte) — sie zählen nur als
|
||||
Karrierepunkte des jeweiligen Beraters.
|
||||
@endif
|
||||
@if ($stornoFloorPoints !== null && $stornoFloorPoints > 0)
|
||||
Nicht abgezogen: <strong>{{ \App\Services\Util::formatNumber($stornoFloorPoints) }} Punkte</strong>
|
||||
aus negativen Monatssalden einzelner Berater (Stornos über die Buchungen des Monats hinaus) —
|
||||
die Provisionsberechnung wertet einen negativen Saldo mit 0.
|
||||
@endif
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -213,7 +213,7 @@ it('erstellt einen CSV-Export fuer die Statistik-Uebersicht', function () {
|
|||
$content = backofficeStatisticsStreamedContent($response);
|
||||
|
||||
expect($response)->toBeInstanceOf(StreamedResponse::class);
|
||||
expect($response->headers->get('content-disposition'))->toContain('backoffice-statistik-uebersicht-05-2026.csv');
|
||||
expect($response->headers->get('content-disposition'))->toContain('backoffice-statistik-uebersicht-commission-05-2026.csv');
|
||||
expect($content)->toContain('Linie;Berater;Neupartner;Teamabos');
|
||||
expect($content)->toContain('Neue Teamabos');
|
||||
expect($content)->toContain('"Linie 1";2;1;1;1;1;1;400;700;700;0;0;1100;1;300');
|
||||
|
|
@ -226,7 +226,7 @@ it('erstellt einen CSV-Export fuer Detaildaten mit Karriere-Level und Summenzeil
|
|||
$drilldownService
|
||||
->shouldReceive('details')
|
||||
->once()
|
||||
->with($vip, 0, 'shop_1000', 5, 2026, false)
|
||||
->with($vip, 0, 'shop_1000', 5, 2026, false, 'commission')
|
||||
->andReturn([
|
||||
'metric' => 'shop_1000',
|
||||
'metric_label' => '1000 Punkte Shop',
|
||||
|
|
@ -297,7 +297,7 @@ it('nutzt den gespeicherten Zeitraum fuer Detailansicht und Export', function ()
|
|||
$drilldownService
|
||||
->shouldReceive('details')
|
||||
->once()
|
||||
->with($vip, 1, 'consultants', 4, 2026, false)
|
||||
->with($vip, 1, 'consultants', 4, 2026, false, 'commission')
|
||||
->andReturn([
|
||||
'metric' => 'consultants',
|
||||
'metric_label' => 'Berater',
|
||||
|
|
@ -313,7 +313,7 @@ it('nutzt den gespeicherten Zeitraum fuer Detailansicht und Export', function ()
|
|||
$drilldownService
|
||||
->shouldReceive('details')
|
||||
->once()
|
||||
->with($vip, 1, 'consultants', 4, 2026, false)
|
||||
->with($vip, 1, 'consultants', 4, 2026, false, 'commission')
|
||||
->andReturn([
|
||||
'metric' => 'consultants',
|
||||
'metric_label' => 'Berater',
|
||||
|
|
@ -353,7 +353,12 @@ it('rendert eine Suche in der Detailtabelle', function () {
|
|||
expect($html)->toContain('backoffice-statistics-detail-search');
|
||||
expect($html)->toContain('backoffice-statistics-detail-table');
|
||||
expect($html)->toContain('data-sortable="true"');
|
||||
expect($html)->toContain('getSortValue');
|
||||
// Die Sortier-/Suchlogik liegt seit 09/2026 im gemeinsamen Partial, damit die
|
||||
// Kundenabo-Liste dieselbe Implementierung nutzt statt einer Kopie.
|
||||
expect($html)->toContain("@include('components._sortable_table'");
|
||||
expect(file_get_contents(resource_path('views/components/_sortable_table.blade.php')))
|
||||
->toContain('getSortValue')
|
||||
->toContain('data-sort-direction');
|
||||
expect($html)->toContain('user_backoffice_statistics_export');
|
||||
expect($html)->toContain('Datenschutz-Hinweis');
|
||||
expect($html)->toContain('Karriere-Level');
|
||||
|
|
|
|||
|
|
@ -31,7 +31,9 @@ function boMakeUser(?int $sponsorId = null, ?string $activeDate = null, ?string
|
|||
'lang' => 'de',
|
||||
'm_level' => 1,
|
||||
'm_sponsor' => $sponsorId,
|
||||
'active_date' => $activeDate,
|
||||
// Ohne active_date steht ein Berater nicht im Provisionsbaum (K11) — wie
|
||||
// im TreeCalcBot. Der Standard liegt vor allen Testmonaten.
|
||||
'active_date' => $activeDate ?? '2026-01-15',
|
||||
'payment_account' => $paymentAccount ?? now()->addYear(),
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -422,8 +422,11 @@ it('summiert Punkte ohne Payline-Wirkung (status_points 2) als eigene Kennzahl',
|
|||
|
||||
$overview = app(BackofficeDashboardService::class)->overview($root, 6, 2026);
|
||||
|
||||
// K11: „Umsatz (Punkte)" sind die Provisionspunkte — die Nur-KP-Buchung steckt
|
||||
// nicht mehr darin, sondern wird gesondert ausgewiesen.
|
||||
expect($overview['non_payline_points'])->toBe(200.0)
|
||||
->and($overview['totals']['total_points'])->toBe(350.0);
|
||||
->and($overview['totals']['total_points'])->toBe(150.0)
|
||||
->and($overview['totals']['own_points'])->toBe(350.0);
|
||||
});
|
||||
|
||||
it('ignoriert Abo-Punkte geloeschter Produkte im Soll der Top-10-Umsatzliste', function (): void {
|
||||
|
|
|
|||
186
tests/Feature/SalesVolumeRunningTotalTest.php
Normal file
186
tests/Feature/SalesVolumeRunningTotalTest.php
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
<?php
|
||||
|
||||
use App\Models\UserBusinessStructure;
|
||||
use App\Models\UserSalesVolume;
|
||||
use App\Services\BusinessPlan\SalesPointsVolume;
|
||||
use App\Services\BusinessPlan\SalesVolumeRunningTotalService;
|
||||
use App\Services\Database\ConcurrencyError;
|
||||
use App\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Ticket C2 (Entwicklungsplan September 2026): Laufstand-Drift in
|
||||
* `user_sales_volumes` — Erkennung, Reparatur, Sicherheitsnetz-Kommando und
|
||||
* die sperrende Lesart der Neuberechnung.
|
||||
*/
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Carbon::setTestNow('2026-09-15 10:00:00');
|
||||
});
|
||||
|
||||
afterEach(fn () => Carbon::setTestNow());
|
||||
|
||||
function rtUser(): User
|
||||
{
|
||||
return User::forceCreate([
|
||||
'email' => 'rt-'.uniqid('', true).'@example.test',
|
||||
'password' => bcrypt('secret'),
|
||||
'lang' => 'de',
|
||||
'm_level' => 1,
|
||||
'active_date' => '2026-01-01',
|
||||
'payment_account' => '2027-01-01',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Buchung mit explizit gesetztem Laufstand — so, wie sie eine Transaktion
|
||||
* hinterlassen hat, die die Nachbarzeile nicht sah.
|
||||
*/
|
||||
function rtRow(User $user, float $points, float $runningShop, float $runningTp = 0, int $status = 2, int $statusPoints = 1, int $month = 9): UserSalesVolume
|
||||
{
|
||||
return UserSalesVolume::forceCreate([
|
||||
'user_id' => $user->id,
|
||||
'month' => $month,
|
||||
'year' => 2026,
|
||||
'date' => sprintf('2026-%02d-10', $month),
|
||||
'points' => $points,
|
||||
'total_net' => $points,
|
||||
'status' => $status,
|
||||
'status_points' => $statusPoints,
|
||||
'month_KP_points' => $runningTp,
|
||||
'month_TP_points' => $runningTp,
|
||||
'month_shop_points' => $runningShop,
|
||||
]);
|
||||
}
|
||||
|
||||
it('erkennt einen ueberholten Laufstand und rechnet ihn neu', function () {
|
||||
$partner = rtUser();
|
||||
$healthy = rtUser();
|
||||
|
||||
// Fall aus dem Messlauf (User 848, 10.09.): 129 → 506, dann 51 → 428 statt 557.
|
||||
rtRow($partner, 377, 377);
|
||||
rtRow($partner, 129, 506);
|
||||
rtRow($partner, 51, 428);
|
||||
|
||||
rtRow($healthy, 100, 100);
|
||||
rtRow($healthy, 20, 120);
|
||||
|
||||
$service = app(SalesVolumeRunningTotalService::class);
|
||||
$drift = $service->detectDrift(9, 2026);
|
||||
|
||||
expect($drift)->toHaveKey($partner->id)
|
||||
->and($drift)->not->toHaveKey($healthy->id)
|
||||
->and($drift[$partner->id])->toBe(['rows' => 557.0, 'running' => 428.0]);
|
||||
|
||||
$repaired = $service->repair(9, 2026);
|
||||
|
||||
expect(array_keys($repaired))->toBe([$partner->id]);
|
||||
|
||||
$rows = UserSalesVolume::where('user_id', $partner->id)->orderBy('id')->pluck('month_shop_points')->map(fn ($v) => (float) $v)->all();
|
||||
|
||||
expect($rows)->toBe([377.0, 506.0, 557.0])
|
||||
->and($service->detectDrift(9, 2026))->toBe([]);
|
||||
});
|
||||
|
||||
it('meldet keine Drift fuer reine Karrierepunkte und andere Monate', function () {
|
||||
$partner = rtUser();
|
||||
|
||||
// KP-only (Neupartner-Einschreibung) zaehlt nicht zu TP + Shop: Laufstand bleibt 0 + 80.
|
||||
rtRow($partner, 80, 80);
|
||||
rtRow($partner, 50, 80, 0, 5, 2);
|
||||
|
||||
// Vormonat mit eigenem Laufstand — darf die September-Pruefung nicht beeinflussen.
|
||||
rtRow($partner, 999, 999, 0, 2, 1, 8);
|
||||
|
||||
$service = app(SalesVolumeRunningTotalService::class);
|
||||
|
||||
expect($service->detectDrift(9, 2026))->toBe([])
|
||||
->and($service->detectDrift(8, 2026))->toBe([]);
|
||||
});
|
||||
|
||||
it('prueft im Nachtlauf den laufenden Monat und den Vormonat nur solange er offen ist', function () {
|
||||
$service = app(SalesVolumeRunningTotalService::class);
|
||||
|
||||
expect($service->monthsToGuard())->toBe([
|
||||
['month' => 8, 'year' => 2026],
|
||||
['month' => 9, 'year' => 2026],
|
||||
]);
|
||||
|
||||
UserBusinessStructure::create(['month' => 8, 'year' => 2026, 'completed' => true, 'status' => 1]);
|
||||
|
||||
expect($service->monthsToGuard())->toBe([['month' => 9, 'year' => 2026]]);
|
||||
|
||||
Carbon::setTestNow('2026-10-01 02:45:00');
|
||||
|
||||
expect($service->monthsToGuard())->toBe([
|
||||
['month' => 9, 'year' => 2026],
|
||||
['month' => 10, 'year' => 2026],
|
||||
]);
|
||||
});
|
||||
|
||||
it('repariert per Kommando, im Dry-Run nur Anzeige', function () {
|
||||
$partner = rtUser();
|
||||
rtRow($partner, 129, 129);
|
||||
rtRow($partner, 51, 51);
|
||||
|
||||
$this->artisan('sales:repair-running-totals', ['--dry-run' => true])
|
||||
->expectsOutputToContain('Laufstaende 09/2026: 1 User mit Drift (DRY-RUN, nichts geschrieben)')
|
||||
->expectsOutputToContain('Laufstaende 08/2026: keine Abweichung.')
|
||||
->assertSuccessful();
|
||||
|
||||
expect((float) UserSalesVolume::where('user_id', $partner->id)->orderByDesc('id')->value('month_shop_points'))->toBe(51.0);
|
||||
|
||||
$this->artisan('sales:repair-running-totals')
|
||||
->expectsOutputToContain('Laufstaende 09/2026: 1 User mit Drift, neu berechnet')
|
||||
->assertSuccessful();
|
||||
|
||||
expect((float) UserSalesVolume::where('user_id', $partner->id)->orderByDesc('id')->value('month_shop_points'))->toBe(180.0);
|
||||
|
||||
$this->artisan('sales:repair-running-totals')
|
||||
->expectsOutputToContain('Laufstaende 09/2026: keine Abweichung.')
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
it('rechnet einen abgerechneten Monat nur mit --force neu', function () {
|
||||
$partner = rtUser();
|
||||
rtRow($partner, 129, 129, 0, 2, 1, 8);
|
||||
rtRow($partner, 51, 51, 0, 2, 1, 8);
|
||||
UserBusinessStructure::create(['month' => 8, 'year' => 2026, 'completed' => true, 'status' => 1]);
|
||||
|
||||
$this->artisan('sales:repair-running-totals', ['month' => 8, 'year' => 2026])
|
||||
->expectsOutputToContain('Monat 08/2026 ist abgerechnet')
|
||||
->assertFailed();
|
||||
|
||||
expect((float) UserSalesVolume::where('user_id', $partner->id)->orderByDesc('id')->value('month_shop_points'))->toBe(51.0);
|
||||
|
||||
$this->artisan('sales:repair-running-totals', ['month' => 8, 'year' => 2026, '--dry-run' => true])
|
||||
->expectsOutputToContain('1 User mit Drift (DRY-RUN')
|
||||
->assertSuccessful();
|
||||
|
||||
$this->artisan('sales:repair-running-totals', ['month' => 8, 'year' => 2026, '--force' => true])
|
||||
->expectsOutputToContain('neu berechnet')
|
||||
->assertSuccessful();
|
||||
|
||||
expect((float) UserSalesVolume::where('user_id', $partner->id)->orderByDesc('id')->value('month_shop_points'))->toBe(180.0);
|
||||
});
|
||||
|
||||
it('liest die Zeilen fuer die Neuberechnung sperrend', function () {
|
||||
$query = SalesPointsVolume::runningTotalRows(1, 9, 2026);
|
||||
|
||||
expect($query->getQuery()->lock)->toBeTrue();
|
||||
});
|
||||
|
||||
it('erkennt Deadlocks und Lock-Timeouts als Concurrency-Fehler', function () {
|
||||
$deadlock = new QueryException('mysql', 'update x', [], new PDOException('SQLSTATE[40001]: Serialization failure: 1213 Deadlock found when trying to get lock'));
|
||||
$timeout = new QueryException('mysql', 'update x', [], new PDOException('SQLSTATE[HY000]: General error: 1205 Lock wait timeout exceeded; try restarting transaction'));
|
||||
$other = new QueryException('mysql', 'insert x', [], new PDOException('SQLSTATE[23000]: Integrity constraint violation'));
|
||||
|
||||
expect(ConcurrencyError::isCausedBy($deadlock))->toBeTrue()
|
||||
->and(ConcurrencyError::isCausedBy($timeout))->toBeTrue()
|
||||
->and(ConcurrencyError::isCausedBy($other))->toBeFalse()
|
||||
->and(ConcurrencyError::isCausedBy(new RuntimeException('Rechnung kaputt')))->toBeFalse();
|
||||
});
|
||||
248
tests/Feature/StatisticsCommissionTreeTest.php
Normal file
248
tests/Feature/StatisticsCommissionTreeTest.php
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
<?php
|
||||
|
||||
use App\Models\BackofficeStatisticsSnapshot;
|
||||
use App\Models\UserBusiness;
|
||||
use App\Models\UserBusinessStructure;
|
||||
use App\Models\UserSalesVolume;
|
||||
use App\Services\Backoffice\BackofficeDashboardService;
|
||||
use App\Services\Backoffice\BackofficeDrilldownService;
|
||||
use App\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* K11 (Kundenentscheid 2026-09-14): Die Team-Statistik rechnet standardmaessig mit
|
||||
* dem Provisionsbaum und den Provisionspunkten — denselben Mitgliedern und
|
||||
* Punkten wie die Team-Struktur. Die gesamte Downline bleibt per Umschalter
|
||||
* erreichbar.
|
||||
*/
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Carbon::setTestNow('2026-09-15 10:00:00');
|
||||
});
|
||||
|
||||
afterEach(fn () => Carbon::setTestNow());
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
*/
|
||||
function sctUser(?int $sponsor = null, array $overrides = []): User
|
||||
{
|
||||
return User::forceCreate(array_merge([
|
||||
'email' => 'sct-'.uniqid('', true).'@example.test',
|
||||
'password' => bcrypt('secret'),
|
||||
'lang' => 'de',
|
||||
'm_level' => 1,
|
||||
'm_sponsor' => $sponsor,
|
||||
'active_date' => '2026-01-01',
|
||||
'payment_account' => '2027-01-01',
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
function sctVolume(User $user, float $points, int $status = 1, int $statusPoints = 1, int $month = 9): UserSalesVolume
|
||||
{
|
||||
return UserSalesVolume::forceCreate([
|
||||
'user_id' => $user->id,
|
||||
'month' => $month,
|
||||
'year' => 2026,
|
||||
'date' => sprintf('2026-%02d-05', $month),
|
||||
'points' => $points,
|
||||
'total_net' => $points,
|
||||
'status' => $status,
|
||||
'status_points' => $statusPoints,
|
||||
'status_turnover' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
it('nimmt in den Provisionsbaum nur Berater mit Mitgliedsebene und Abrechnungskonto auf und kappt deren Unterbaum', function () {
|
||||
$root = sctUser();
|
||||
$partner = sctUser($root->id); // Linie 1, im Provisionsbaum
|
||||
$noLevel = sctUser($root->id, ['m_level' => null]); // Linie 1, ohne Ebene → nicht im Provisionsbaum
|
||||
$underNoLevel = sctUser($noLevel->id); // Linie 2, regulaer — aber unter gekapptem Knoten
|
||||
$noAccount = sctUser($partner->id, ['payment_account' => null]); // Linie 2, ohne Abrechnungskonto
|
||||
$expired = sctUser($partner->id, ['payment_account' => '2026-08-01']); // Linie 2, abgelaufen — zaehlt im Provisionsbaum trotzdem
|
||||
$laterActive = sctUser($partner->id, ['active_date' => '2026-10-01']); // Linie 2, erst nach Monatsende aktiv
|
||||
|
||||
sctVolume($partner, 100);
|
||||
sctVolume($noLevel, 100);
|
||||
sctVolume($underNoLevel, 100);
|
||||
sctVolume($noAccount, 100);
|
||||
sctVolume($expired, 100);
|
||||
sctVolume($laterActive, 100);
|
||||
|
||||
$service = app(BackofficeDashboardService::class);
|
||||
|
||||
$commission = $service->overview($root, 9, 2026);
|
||||
$downline = $service->overview($root, 9, 2026, BackofficeDashboardService::TREE_DOWNLINE);
|
||||
|
||||
expect($commission['_meta']['tree'])->toBe('commission')
|
||||
->and($commission['tree'])->toBe('commission')
|
||||
->and($commission['lines'][1]['user_ids'])->toBe([$partner->id])
|
||||
->and($commission['lines'][2]['user_ids'])->toBe([$expired->id])
|
||||
->and($commission['lines'])->not->toHaveKey(3)
|
||||
->and((float) $commission['totals']['total_points'])->toBe(200.0)
|
||||
->and($commission['totals']['consultants'])->toBe(2);
|
||||
|
||||
expect($downline['_meta']['tree'])->toBe('downline')
|
||||
->and(collect($downline['lines'][1]['user_ids'])->sort()->values()->all())->toBe(collect([$partner->id, $noLevel->id])->sort()->values()->all())
|
||||
->and(count($downline['lines'][2]['user_ids']))->toBe(4)
|
||||
->and((float) $downline['totals']['total_points'])->toBe(600.0);
|
||||
});
|
||||
|
||||
it('rechnet Umsatz (Punkte) als Provisionspunkte: ohne Nur-KP-Buchungen und mit Knotenregel fuer negative Salden', function () {
|
||||
$root = sctUser();
|
||||
$a = sctUser($root->id);
|
||||
$b = sctUser($root->id);
|
||||
|
||||
sctVolume($a, 300); // Eigenbestellung → TP
|
||||
sctVolume($a, 50, 1, 2); // Neupartner-Einschreibung: nur KP → nicht in den Provisionspunkten
|
||||
sctVolume($a, 120, 2); // Kundenbestellung → Shop
|
||||
sctVolume($b, 80);
|
||||
sctVolume($b, -100, 6); // Storno ueber den Monat hinaus → Saldo -20 → Knotenregel 0
|
||||
|
||||
$overview = app(BackofficeDashboardService::class)->overview($root, 9, 2026);
|
||||
$line = $overview['lines'][1];
|
||||
|
||||
expect((float) $line['total_points'])->toBe(420.0)
|
||||
->and((float) $line['own_points'])->toBe(330.0) // Aufteilung bleibt Zeilensumme: 300 + 50 + 80 - 100
|
||||
->and((float) $line['external_points'])->toBe(120.0)
|
||||
->and((float) $line['storno_floor_points'])->toBe(20.0)
|
||||
->and((float) $overview['non_payline_points'])->toBe(50.0)
|
||||
->and((float) $overview['storno_floor_points'])->toBe(20.0)
|
||||
->and((float) $overview['totals']['total_points'])->toBe(420.0);
|
||||
|
||||
$details = app(BackofficeDrilldownService::class)->details($root, 0, 'total_points', 9, 2026);
|
||||
$rowA = collect($details['rows'])->firstWhere('user_id', $a->id);
|
||||
$rowB = collect($details['rows'])->firstWhere('user_id', $b->id);
|
||||
|
||||
expect($details['summary']['total_points'])->toBe(420.0)
|
||||
->and($rowA['total_points'])->toBe(420.0)
|
||||
->and($rowB['total_points'])->toBe(0.0)
|
||||
->and($rowB['storno_floor_points'])->toBe(20.0)
|
||||
->and($rowB['own_points'])->toBe(-20.0);
|
||||
});
|
||||
|
||||
it('liefert die Provisionspunkte je Linie so, wie die Team-Struktur sie aus den Laufstaenden bildet', function () {
|
||||
$root = sctUser();
|
||||
$line1 = sctUser($root->id);
|
||||
$line2 = sctUser($line1->id);
|
||||
|
||||
// Laufstaende wie SalesPointsVolume::reCalculateSalesPointsVolume() sie fuehren
|
||||
// wuerde; getPointsTPSum() der letzten Zeile ist die Basis der Struktur.
|
||||
UserSalesVolume::forceCreate(['user_id' => $line1->id, 'month' => 9, 'year' => 2026, 'date' => '2026-09-05', 'points' => 200, 'total_net' => 200, 'status' => 1, 'status_points' => 1, 'month_KP_points' => 200, 'month_TP_points' => 200, 'month_shop_points' => 0]);
|
||||
UserSalesVolume::forceCreate(['user_id' => $line1->id, 'month' => 9, 'year' => 2026, 'date' => '2026-09-06', 'points' => 30, 'total_net' => 30, 'status' => 1, 'status_points' => 2, 'month_KP_points' => 230, 'month_TP_points' => 200, 'month_shop_points' => 0]);
|
||||
UserSalesVolume::forceCreate(['user_id' => $line1->id, 'month' => 9, 'year' => 2026, 'date' => '2026-09-07', 'points' => 75.5, 'total_net' => 75.5, 'status' => 2, 'status_points' => 1, 'month_KP_points' => 230, 'month_TP_points' => 200, 'month_shop_points' => 75.5]);
|
||||
UserSalesVolume::forceCreate(['user_id' => $line2->id, 'month' => 9, 'year' => 2026, 'date' => '2026-09-08', 'points' => 40, 'total_net' => 40, 'status' => 5, 'status_points' => 1, 'month_KP_points' => 40, 'month_TP_points' => 40, 'month_shop_points' => 0]);
|
||||
|
||||
$overview = app(BackofficeDashboardService::class)->overview($root, 9, 2026);
|
||||
|
||||
$lastRowOfLine1 = UserSalesVolume::where('user_id', $line1->id)->orderByDesc('id')->first();
|
||||
$lastRowOfLine2 = UserSalesVolume::where('user_id', $line2->id)->orderByDesc('id')->first();
|
||||
|
||||
expect((float) $overview['lines'][1]['total_points'])->toBe((float) $lastRowOfLine1->getPointsTPSum())
|
||||
->and((float) $overview['lines'][2]['total_points'])->toBe((float) $lastRowOfLine2->getPointsTPSum())
|
||||
->and((float) $overview['totals']['total_points'])->toBe(315.5);
|
||||
});
|
||||
|
||||
it('haelt je Baum-Definition einen eigenen Snapshot fuer abgeschlossene Monate', function () {
|
||||
$root = sctUser();
|
||||
$partner = sctUser($root->id);
|
||||
$noLevel = sctUser($root->id, ['m_level' => null]);
|
||||
|
||||
sctVolume($partner, 100, 1, 1, 8);
|
||||
sctVolume($noLevel, 100, 1, 1, 8);
|
||||
|
||||
$service = app(BackofficeDashboardService::class);
|
||||
$service->storeSnapshot($root, 8, 2026, BackofficeDashboardService::TREE_COMMISSION);
|
||||
$service->storeSnapshot($root, 8, 2026, BackofficeDashboardService::TREE_DOWNLINE);
|
||||
|
||||
expect(BackofficeStatisticsSnapshot::query()->where('user_id', $root->id)->count())->toBe(2)
|
||||
->and(BackofficeStatisticsSnapshot::query()->where('user_id', $root->id)->pluck('tree')->sort()->values()->all())->toBe(['commission', 'downline']);
|
||||
|
||||
$noLevel->update(['m_level' => 1]);
|
||||
|
||||
$commission = $service->overview($root, 8, 2026);
|
||||
$downline = $service->overview($root, 8, 2026, BackofficeDashboardService::TREE_DOWNLINE);
|
||||
|
||||
expect($commission['_meta']['source'])->toBe('snapshot')
|
||||
->and((float) $commission['totals']['total_points'])->toBe(100.0)
|
||||
->and($downline['_meta']['source'])->toBe('snapshot')
|
||||
->and((float) $downline['totals']['total_points'])->toBe(200.0);
|
||||
|
||||
// Die Detailliste des eingefrorenen Monats folgt derselben Baum-Definition
|
||||
// (die Berater-Liste selbst verlangt weiterhin eine Mitgliedsebene, deshalb
|
||||
// hier ueber die Punkte-Liste).
|
||||
$downlineDetails = app(BackofficeDrilldownService::class)->details($root, 0, 'total_points', 8, 2026, false, BackofficeDashboardService::TREE_DOWNLINE);
|
||||
$commissionDetails = app(BackofficeDrilldownService::class)->details($root, 0, 'total_points', 8, 2026);
|
||||
|
||||
expect(collect($downlineDetails['rows'])->pluck('user_id')->sort()->values()->all())->toBe(collect([$partner->id, $noLevel->id])->sort()->values()->all())
|
||||
->and(collect($commissionDetails['rows'])->pluck('user_id')->all())->toBe([$partner->id]);
|
||||
});
|
||||
|
||||
it('faellt bei unbekannter Baum-Definition auf den Provisionsbaum zurueck', function () {
|
||||
expect(BackofficeDashboardService::normalizeTree('foo'))->toBe('commission')
|
||||
->and(BackofficeDashboardService::normalizeTree(null))->toBe('commission')
|
||||
->and(BackofficeDashboardService::normalizeTree('downline'))->toBe('downline');
|
||||
});
|
||||
|
||||
it('meldet im Messlauf Laufstand-Drift und repariert ihn auf Wunsch', function () {
|
||||
$root = sctUser(null, ['admin' => 1]);
|
||||
$partner = sctUser($root->id);
|
||||
|
||||
// Zwei Buchungen, deren Laufstaende sich ueberholt haben (zeitgleiche
|
||||
// Abo-Ausfuehrungen): die letzte Zeile kennt die 129 Punkte der vorherigen nicht.
|
||||
UserSalesVolume::forceCreate(['user_id' => $partner->id, 'month' => 9, 'year' => 2026, 'date' => '2026-09-10', 'points' => 129, 'total_net' => 129, 'status' => 2, 'status_points' => 1, 'month_KP_points' => 0, 'month_TP_points' => 0, 'month_shop_points' => 129]);
|
||||
UserSalesVolume::forceCreate(['user_id' => $partner->id, 'month' => 9, 'year' => 2026, 'date' => '2026-09-10', 'points' => 51, 'total_net' => 51, 'status' => 2, 'status_points' => 1, 'month_KP_points' => 0, 'month_TP_points' => 0, 'month_shop_points' => 51]);
|
||||
|
||||
$this->artisan('statistics:compare-trees', ['month' => 9, 'year' => 2026, '--user' => [$root->id]])
|
||||
->expectsOutputToContain('1 User mit Laufstand-Drift')
|
||||
->assertSuccessful();
|
||||
|
||||
$this->artisan('statistics:compare-trees', ['month' => 9, 'year' => 2026, '--user' => [$root->id], '--repair-running-totals' => true])
|
||||
->assertSuccessful();
|
||||
|
||||
$lastRow = UserSalesVolume::where('user_id', $partner->id)->orderByDesc('id')->first();
|
||||
|
||||
expect((float) $lastRow->month_shop_points)->toBe(180.0);
|
||||
|
||||
$this->artisan('statistics:compare-trees', ['month' => 9, 'year' => 2026, '--user' => [$root->id]])
|
||||
->expectsOutputToContain('keine Abweichung')
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
it('nimmt fuer abgeschlossene Monate die Struktur des gespeicherten Provisionslaufs statt des heutigen Baums', function () {
|
||||
$root = sctUser();
|
||||
$stayed = sctUser($root->id); // damals wie heute unter root
|
||||
$movedAway = sctUser(null); // damals unter root, heute deaktiviert (kein Sponsor)
|
||||
$joinedLater = sctUser($root->id); // heute unter root, im Lauf vom 01.09. nicht im Baum
|
||||
$grandChild = sctUser($movedAway->id); // damals unter movedAway (Linie 2)
|
||||
|
||||
sctVolume($stayed, 100, 1, 1, 8);
|
||||
sctVolume($movedAway, 200, 1, 1, 8);
|
||||
sctVolume($joinedLater, 400, 1, 1, 8);
|
||||
sctVolume($grandChild, 50, 1, 1, 8);
|
||||
|
||||
$structure = UserBusinessStructure::create(['month' => 8, 'year' => 2026, 'completed' => true, 'status' => 1]);
|
||||
$sponsorJson = fn (?int $sponsorId) => (object) ['is_sponsor' => $sponsorId !== null, 'user_id' => $sponsorId ?? false];
|
||||
|
||||
foreach ([[$root, null], [$stayed, $root->id], [$movedAway, $root->id], [$grandChild, $movedAway->id]] as [$user, $sponsorId]) {
|
||||
UserBusiness::forceCreate(['user_id' => $user->id, 'month' => 8, 'year' => 2026, 'b_structure_id' => $structure->id, 'sponsor' => $sponsorJson($sponsorId), 'version' => 2]);
|
||||
}
|
||||
|
||||
$service = app(BackofficeDashboardService::class);
|
||||
$august = $service->overview($root, 8, 2026);
|
||||
|
||||
expect($august['tree_source'])->toBe('stored_structure')
|
||||
->and(collect($august['lines'][1]['user_ids'])->sort()->values()->all())->toBe(collect([$stayed->id, $movedAway->id])->sort()->values()->all())
|
||||
->and($august['lines'][2]['user_ids'])->toBe([$grandChild->id])
|
||||
->and((float) $august['totals']['total_points'])->toBe(350.0);
|
||||
|
||||
// Der laufende Monat bleibt live (heutiger Baum), auch wenn ein Nachtlauf existiert.
|
||||
UserBusinessStructure::create(['month' => 9, 'year' => 2026, 'completed' => true, 'status' => 1]);
|
||||
$september = $service->overview($root, 9, 2026);
|
||||
|
||||
expect($september['tree_source'])->toBe('live')
|
||||
->and(collect($september['lines'][1]['user_ids'])->sort()->values()->all())->toBe(collect([$stayed->id, $joinedLater->id])->sort()->values()->all());
|
||||
});
|
||||
|
|
@ -145,9 +145,16 @@ it('zeigt negative Monatssalden im Drilldown und bleibt mit der Uebersicht gleic
|
|||
$overview = app(BackofficeDashboardService::class)->overview($root, 9, 2026);
|
||||
$details = app(BackofficeDrilldownService::class)->details($root, 0, 'total_points', 9, 2026);
|
||||
|
||||
expect((float) $overview['totals']['total_points'])->toBe(60.0)
|
||||
->and($details['summary']['total_points'])->toBe(60.0)
|
||||
->and($details['rows'])->toHaveCount(2);
|
||||
// Knotenregel des Provisionsbaums (K11): ein negativer Monatssaldo zaehlt 0,
|
||||
// die Kachel zeigt also 100 und weist die 40 gesondert als nicht abgezogen aus.
|
||||
// Der Berater bleibt in der Liste (Eigenpunkte -40, Gesamt 0).
|
||||
expect((float) $overview['totals']['total_points'])->toBe(100.0)
|
||||
->and((float) $overview['storno_floor_points'])->toBe(40.0)
|
||||
->and($details['summary']['total_points'])->toBe(100.0)
|
||||
->and($details['summary']['storno_floor_points'])->toBe(40.0)
|
||||
->and($details['rows'])->toHaveCount(2)
|
||||
->and(collect($details['rows'])->firstWhere('user_id', $minus->id)['own_points'])->toBe(-40.0)
|
||||
->and(collect($details['rows'])->firstWhere('user_id', $minus->id)['total_points'])->toBe(0.0);
|
||||
});
|
||||
|
||||
it('behaelt die Schwelle der 1000-Punkte-Liste', function () {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ uses(TestCase::class);
|
|||
beforeEach(function () {
|
||||
Schema::dropIfExists('user_sales_volumes');
|
||||
Schema::dropIfExists('backoffice_statistics_snapshots');
|
||||
Schema::dropIfExists('user_businesses');
|
||||
Schema::dropIfExists('user_business_structures');
|
||||
Schema::dropIfExists('user_abo_orders');
|
||||
Schema::dropIfExists('user_abo_items');
|
||||
Schema::dropIfExists('products');
|
||||
|
|
@ -160,11 +162,32 @@ beforeEach(function () {
|
|||
$table->timestamps();
|
||||
});
|
||||
|
||||
// Gespeicherter Provisionslauf: fuer abgeschlossene Monate liest die
|
||||
// Statistik die Struktur von dort (hier leer → Live-Baum).
|
||||
Schema::create('user_business_structures', function ($table) {
|
||||
$table->increments('id');
|
||||
$table->unsignedTinyInteger('month');
|
||||
$table->unsignedSmallInteger('year');
|
||||
$table->boolean('completed')->default(false);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('user_businesses', function ($table) {
|
||||
$table->increments('id');
|
||||
$table->unsignedInteger('user_id');
|
||||
$table->unsignedTinyInteger('month');
|
||||
$table->unsignedSmallInteger('year');
|
||||
$table->unsignedInteger('b_structure_id')->nullable();
|
||||
$table->text('sponsor')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('backoffice_statistics_snapshots', function ($table) {
|
||||
$table->id();
|
||||
$table->unsignedInteger('user_id');
|
||||
$table->unsignedSmallInteger('year');
|
||||
$table->unsignedTinyInteger('month');
|
||||
$table->string('tree', 16)->default('commission');
|
||||
$table->json('payload');
|
||||
$table->timestamp('calculated_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue