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>
396 lines
15 KiB
PHP
396 lines
15 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\User;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Services\Backoffice\BackofficeDashboardService;
|
|
use App\Services\Backoffice\BackofficeDrilldownService;
|
|
use App\Services\Backoffice\PersonalStatisticsService;
|
|
use App\Services\BusinessPlan\LiveCommissionService;
|
|
use App\Services\HTMLHelper;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\View\View;
|
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
|
|
|
class BackofficeStatisticsController extends Controller
|
|
{
|
|
private const SESSION_MONTH_KEY = 'backoffice_statistics_month';
|
|
|
|
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,
|
|
private PersonalStatisticsService $personalService
|
|
) {
|
|
$this->middleware('active.account');
|
|
}
|
|
|
|
public function index(Request $request): View
|
|
{
|
|
if (! $request->user()?->isVIP()) {
|
|
abort(404);
|
|
}
|
|
|
|
[$selectedMonth, $selectedYear] = $this->selectedPeriod($request);
|
|
$selectedTree = $this->selectedTree($request);
|
|
$startTime = microtime(true);
|
|
$statistics = $this->dashboardService->overview($request->user(), $selectedMonth, $selectedYear, $selectedTree);
|
|
$performance = [
|
|
'duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
|
|
'source' => $statistics['_meta']['source'] ?? 'live',
|
|
'source_label' => $statistics['_meta']['source_label'] ?? 'Live',
|
|
'calculated_at' => $statistics['_meta']['calculated_at'] ?? null,
|
|
];
|
|
|
|
return view('user.backoffice.statistics.index', [
|
|
'selectedMonth' => $selectedMonth,
|
|
'selectedYear' => $selectedYear,
|
|
'selectedTree' => $selectedTree,
|
|
'treeLabels' => BackofficeDashboardService::treeLabels(),
|
|
'filterMonths' => HTMLHelper::getTransMonths(),
|
|
'filterYears' => HTMLHelper::getYearRange(2022),
|
|
'statistics' => $statistics,
|
|
'performance' => $performance,
|
|
'commission' => app(LiveCommissionService::class)->commission($request->user(), $selectedMonth, $selectedYear),
|
|
]);
|
|
}
|
|
|
|
public function my(Request $request): View
|
|
{
|
|
if (! $request->user()?->isVIP()) {
|
|
abort(404);
|
|
}
|
|
|
|
[$selectedMonth, $selectedYear] = $this->selectedPeriod($request);
|
|
|
|
return view('user.backoffice.statistics.my', [
|
|
'selectedMonth' => $selectedMonth,
|
|
'selectedYear' => $selectedYear,
|
|
'filterMonths' => HTMLHelper::getTransMonths(),
|
|
'filterYears' => HTMLHelper::getYearRange(2022),
|
|
'statistics' => $this->personalService->monthly($request->user(), $selectedMonth, $selectedYear),
|
|
]);
|
|
}
|
|
|
|
public function details(Request $request): View
|
|
{
|
|
if (! $request->user()?->isVIP()) {
|
|
abort(404);
|
|
}
|
|
|
|
[$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');
|
|
|
|
return view('user.backoffice.statistics.details', [
|
|
'selectedMonth' => $selectedMonth,
|
|
'selectedYear' => $selectedYear,
|
|
'selectedTree' => $selectedTree,
|
|
'details' => $this->drilldownService->details($request->user(), $line, $metric, $selectedMonth, $selectedYear, $onlyNew, $selectedTree),
|
|
]);
|
|
}
|
|
|
|
public function export(Request $request): StreamedResponse
|
|
{
|
|
if (! $request->user()?->isVIP()) {
|
|
abort(404);
|
|
}
|
|
|
|
[$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, $selectedTree);
|
|
$filename = sprintf(
|
|
'backoffice-statistik-%s%s-linie-%s-%02d-%d.csv',
|
|
$metric,
|
|
$details['only_new'] ? '-neu' : '',
|
|
$line === 0 ? 'alle' : $line,
|
|
$selectedMonth,
|
|
$selectedYear
|
|
);
|
|
|
|
return response()->streamDownload(function () use ($details): void {
|
|
$output = fopen('php://output', 'w');
|
|
|
|
fwrite($output, "\xEF\xBB\xBF");
|
|
fputcsv($output, $this->csvHeaders($details['metric']), ';');
|
|
|
|
foreach ($details['rows'] as $row) {
|
|
fputcsv($output, self::csvSafeRow($this->csvRow($details['metric'], $row)), ';');
|
|
}
|
|
|
|
fputcsv($output, []);
|
|
fputcsv($output, self::csvSafeRow($this->csvSummaryRow($details)), ';');
|
|
|
|
fclose($output);
|
|
}, $filename, [
|
|
'Content-Type' => 'text/csv; charset=UTF-8',
|
|
]);
|
|
}
|
|
|
|
public function overviewExport(Request $request): StreamedResponse
|
|
{
|
|
if (! $request->user()?->isVIP()) {
|
|
abort(404);
|
|
}
|
|
|
|
[$selectedMonth, $selectedYear] = $this->selectedPeriod($request);
|
|
$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');
|
|
|
|
fwrite($output, "\xEF\xBB\xBF");
|
|
fputcsv($output, $this->overviewCsvHeaders(), ';');
|
|
|
|
foreach ($statistics['lines'] as $line) {
|
|
fputcsv($output, self::csvSafeRow($this->overviewCsvRow($line)), ';');
|
|
}
|
|
|
|
fputcsv($output, []);
|
|
fputcsv($output, self::csvSafeRow($this->overviewCsvRow($statistics['totals'])), ';');
|
|
|
|
fclose($output);
|
|
}, $filename, [
|
|
'Content-Type' => 'text/csv; charset=UTF-8',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @return array{0: int, 1: int}
|
|
*/
|
|
private function selectedPeriod(Request $request): array
|
|
{
|
|
$selectedMonth = max(1, min(12, (int) $request->get('month', session(self::SESSION_MONTH_KEY, now()->month))));
|
|
$selectedYear = max(2020, min((int) now()->year + 1, (int) $request->get('year', session(self::SESSION_YEAR_KEY, now()->year))));
|
|
|
|
session([
|
|
self::SESSION_MONTH_KEY => $selectedMonth,
|
|
self::SESSION_YEAR_KEY => $selectedYear,
|
|
]);
|
|
|
|
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[]
|
|
*/
|
|
private function overviewCsvHeaders(): array
|
|
{
|
|
return [
|
|
'Linie',
|
|
'Berater',
|
|
'Neupartner',
|
|
'Teamabos',
|
|
'Neue Teamabos',
|
|
'Teamkundenabos',
|
|
'Neue Teamkundenabos',
|
|
'Eigenpunkte',
|
|
'Externe Punkte',
|
|
'Kundenabo-Punkte',
|
|
'Einzelbestellungs-Punkte',
|
|
'Sonstige Kundenpunkte',
|
|
'Gesamtpunkte',
|
|
'1000 Punkte Shop',
|
|
'Umsatz Netto',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $row
|
|
* @return array<int, mixed>
|
|
*/
|
|
private function overviewCsvRow(array $row): array
|
|
{
|
|
return [
|
|
$row['label'] ?? '',
|
|
$row['consultants'] ?? 0,
|
|
$row['new_partners'] ?? 0,
|
|
$row['team_partner_abos'] ?? 0,
|
|
$row['team_partner_abos_new'] ?? 0,
|
|
$row['team_customer_abos'] ?? 0,
|
|
$row['team_customer_abos_new'] ?? 0,
|
|
$row['own_points'] ?? 0,
|
|
$row['external_points'] ?? 0,
|
|
$row['customer_abo_points'] ?? 0,
|
|
$row['customer_single_order_points'] ?? 0,
|
|
$row['customer_other_points'] ?? 0,
|
|
$row['total_points'] ?? 0,
|
|
$row['shop_1000'] ?? 0,
|
|
$row['turnover_net'] ?? 0,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Textzellen fuer Tabellenprogramme als Text kennzeichnen.
|
|
*
|
|
* `fputcsv()` schuetzt die Dateistruktur, nicht die Interpretation: Ein Feld, das
|
|
* mit `=`, `+`, `-` oder `@` beginnt, wird von Excel und LibreOffice als Formel
|
|
* gelesen. Die Werte stammen aus Namen, Kontaktdaten und Freitexten anderer
|
|
* Personen — also nicht aus der Hand dessen, der den Export oeffnet.
|
|
*
|
|
* Zahlen bleiben unangetastet, damit die Spalten rechenbar bleiben.
|
|
*
|
|
* @param array<int, mixed> $row
|
|
* @return array<int, mixed>
|
|
*/
|
|
private static function csvSafeRow(array $row): array
|
|
{
|
|
return array_map(static function ($value) {
|
|
if (! is_string($value) || $value === '') {
|
|
return $value;
|
|
}
|
|
|
|
return str_contains("=+-@\t\r", $value[0]) ? "'".$value : $value;
|
|
}, $row);
|
|
}
|
|
|
|
/**
|
|
* @return string[]
|
|
*/
|
|
private function csvHeaders(string $metric): array
|
|
{
|
|
if ($metric === 'team_customer_abos') {
|
|
return ['Teampartner', 'E-Mail', 'Karriere-Level', 'Anzahl Kundenabos', 'Volumenpunkte gesamt'];
|
|
}
|
|
|
|
if ($metric === 'team_partner_abos') {
|
|
return ['Name', 'E-Mail', 'Karriere-Level', 'Berater', 'Abo-Punkte', 'Status', 'Status-Grund', 'Besteht seit', 'Naechste Ausfuehrung', 'Lieferungen'];
|
|
}
|
|
|
|
if (in_array($metric, ['own_points', 'external_points', 'customer_abo_points', 'customer_single_order_points', 'customer_other_points', 'total_points', 'shop_1000'], true)) {
|
|
return ['Name', 'E-Mail', 'Karriere-Level', 'Eigenpunkte', 'Shop-Punkte', 'Abo-Punkte', 'Sonstige Punkte', 'Kundenpunkte', 'Gesamtpunkte'];
|
|
}
|
|
|
|
if ($metric === 'new_partners') {
|
|
return ['Name', 'E-Mail', 'Telefon', 'Karriere-Level', 'Sponsor', 'Punkte seiner Kunden', 'Gesamt inkl. Eigenbestellung', 'Aktiv seit', 'Account gueltig bis'];
|
|
}
|
|
|
|
return ['Name', 'E-Mail', 'Karriere-Level', 'Aktiv seit', 'Account gueltig bis', 'Account Status'];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $row
|
|
* @return array<int, mixed>
|
|
*/
|
|
private function csvRow(string $metric, array $row): array
|
|
{
|
|
if ($metric === 'team_customer_abos') {
|
|
return [
|
|
$row['name'] ?? '',
|
|
$row['email'] ?? '',
|
|
$row['career_level'] ?? '',
|
|
$row['abo_count'] ?? 0,
|
|
$row['points'] ?? 0,
|
|
];
|
|
}
|
|
|
|
if ($metric === 'team_partner_abos') {
|
|
return [
|
|
$row['name'] ?? '',
|
|
$row['email'] ?? '',
|
|
$row['career_level'] ?? '',
|
|
$row['consultant_name'] ?? $row['name'] ?? '',
|
|
$row['points'] ?? 0,
|
|
$row['status_label'] ?? '',
|
|
$row['status_reason'] ?? '',
|
|
$row['start_date'] ?? '',
|
|
$row['next_date'] ?? '',
|
|
$row['deliveries'] ?? 0,
|
|
];
|
|
}
|
|
|
|
if (in_array($metric, ['own_points', 'external_points', 'customer_abo_points', 'customer_single_order_points', 'customer_other_points', 'total_points', 'shop_1000'], true)) {
|
|
return [
|
|
$row['name'] ?? '',
|
|
$row['email'] ?? '',
|
|
$row['career_level'] ?? '',
|
|
$row['own_points'] ?? 0,
|
|
$row['customer_single_order_points'] ?? 0,
|
|
$row['customer_abo_points'] ?? 0,
|
|
$row['customer_other_points'] ?? 0,
|
|
$row['external_points'] ?? 0,
|
|
$row['total_points'] ?? 0,
|
|
];
|
|
}
|
|
|
|
if ($metric === 'new_partners') {
|
|
return [
|
|
$row['name'] ?? '',
|
|
$row['email'] ?? '',
|
|
$row['phone'] ?? '',
|
|
$row['career_level'] ?? '',
|
|
$row['sponsor'] ?? '',
|
|
$row['customer_points'] ?? 0,
|
|
$row['total_points'] ?? 0,
|
|
$row['active_date'] ?? '',
|
|
$row['payment_account'] ?? '',
|
|
];
|
|
}
|
|
|
|
return [
|
|
$row['name'] ?? '',
|
|
$row['email'] ?? '',
|
|
$row['career_level'] ?? '',
|
|
$row['active_date'] ?? '',
|
|
$row['payment_account'] ?? '',
|
|
$row['account_status'] ?? '',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $details
|
|
* @return array<int, mixed>
|
|
*/
|
|
private function csvSummaryRow(array $details): array
|
|
{
|
|
$metric = $details['metric'];
|
|
$summary = $details['summary'];
|
|
|
|
if ($metric === 'team_customer_abos') {
|
|
return ['Summe', $summary['count'].' Teampartner', '', $summary['abo_count'], $summary['points']];
|
|
}
|
|
|
|
if ($metric === 'team_partner_abos') {
|
|
return ['Summe', $summary['count'].' Eintraege', '', '', $summary['points'], '', '', '', '', $summary['deliveries']];
|
|
}
|
|
|
|
if (in_array($metric, ['own_points', 'external_points', 'customer_abo_points', 'customer_single_order_points', 'customer_other_points', 'total_points', 'shop_1000'], true)) {
|
|
return ['Summe', $summary['count'].' Eintraege', '', $summary['own_points'], $summary['customer_single_order_points'], $summary['customer_abo_points'], $summary['customer_other_points'], $summary['external_points'], $summary['total_points']];
|
|
}
|
|
|
|
if ($metric === 'new_partners') {
|
|
// Neun Werte fuer neun Spalten: die Telefonspalte kam nachtraeglich dazu,
|
|
// wodurch die Summen um eine Spalte verrutscht waren (Kundenpunkte
|
|
// standen unter "Sponsor").
|
|
return ['Summe', $summary['count'].' Eintraege', '', '', '', $summary['customer_points'], $summary['total_points'], '', ''];
|
|
}
|
|
|
|
return ['Summe', $summary['count'].' Eintraege', '', '', '', ''];
|
|
}
|
|
}
|