Statistiken/Abo: Kundenfeedback 2026-07-24 komplett umgesetzt + Steuern: DATEV-Fixes 08+09/2025 (Sammel-Commit Branch-WIP)
Feedback-Umsetzung (dev/2026-07-22/feedback-analyse-2026-07-24.md, alle 10 Punkte):
- VIP-Statistik: eigener Berater ('ich', Linie 0) in Neue-Kundenabos- und beiden Top-10-Listen; Top-10-Umsatz sortiert nach abgerechneten Ist-Punkten + Abo-Anzahl-Spalte; Fußnote nicht-payline-wirksame Punkte (SalesVolumePoints::nonPaylinePoints)
- Führungskräfte-Liste: beide Sichten 'direkt' (an nächster FK abgeschnitten, additiv) und 'gesamt' (komplette Downline), Gesamt-Spalte nach Kundenzählweise
- Team-Abos: je Ebene 'abgerechnet (Monat)' und 'bis zum 25. erwartet'; Payline-Karte mit Teamabo-/Kundenabo-Zählern
- Incentive-Live-Ranking: Qualifizierte zuerst nummeriert (fixt auch isWinner rank<=max_winners)
- Porto: Netto-/Brutto-Kennzeichnung (Berater netto, Endkunden inkl. MwSt., portal_view-Flag; auch CRM-/Shop-Warenkorb + Checkout), Yard::shipping_tax-Fix
- Struktur-Ansicht: Abo-Badges 'A' (eigenes Abo) und '(n)' (Kundenabos) je Teampartner, gebatcht
- Abo-Löschung: Softdelete setzt active=0 (UserAbo deleting-Hook) + Bereinigungs-Migration für gelöschte-aber-aktive Abos
- Doku: technische Analyse + Kundinnen-Feedback-Dokument unter dev/2026-07-22/
- Tests: IncentiveRankingTest, TeamStructureAboBadgesTest, YardShippingTaxTest (neu); BackofficeStatisticsListsTest, TeamAboOverviewTest, PaylineForecastServiceTest, AboAdminDeleteTest, AboOneTimeViewTest erweitert
Außerdem enthalten (Branch-WIP vor dem Feedback): DATEV-Abgleich-Fixes 08+09/2025, USt-ID-Live-Validierung sowie restliche unkommittete Stände (u. a. Abo-Pause-Sprachkeys).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
657d4bd5b7
commit
ac3bd6e8ea
136 changed files with 7610 additions and 2688 deletions
|
|
@ -1,214 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Http\Controllers\Api\KasController;
|
||||
use Exception;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class DeleteAllKasSubdomains extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'subdomains:delete-all-kas
|
||||
{--domain= : Optional nur Subdomains dieser Hauptdomain löschen}
|
||||
{--limit= : Maximale Anzahl zu löschender Subdomains}
|
||||
{--list-file= : Optionaler Pfad für die temporäre JSON-Liste}
|
||||
{--force : Subdomains wirklich löschen; ohne diese Option läuft nur ein Dry-Run}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Löscht alle aktuell in KAS angelegten Subdomains anhand einer temporären KAS-Liste';
|
||||
|
||||
public function __construct(private KasController $kas)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$domain = $this->option('domain') !== null
|
||||
? trim((string) $this->option('domain'), '.')
|
||||
: null;
|
||||
$limit = $this->option('limit') !== null ? (int) $this->option('limit') : null;
|
||||
$isForce = (bool) $this->option('force');
|
||||
|
||||
if ($domain === '') {
|
||||
$this->error('Die Option --domain darf nicht leer sein.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($limit !== null && $limit < 1) {
|
||||
$this->error('Die Option --limit muss größer als 0 sein.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
$candidates = $this->getKasSubdomains($domain);
|
||||
|
||||
if ($limit !== null) {
|
||||
$candidates = $candidates->take($limit)->values();
|
||||
}
|
||||
|
||||
$listFilePath = $this->writeTemporaryList($candidates, $domain, $isForce);
|
||||
$this->printPreview($candidates, $domain, $listFilePath, $isForce);
|
||||
|
||||
if ($candidates->isEmpty()) {
|
||||
$this->info('Keine KAS-Subdomains gefunden.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (! $isForce) {
|
||||
$this->warn('Dry-Run: Es wurde nichts gelöscht. Mit --force werden die Subdomains aus der temporären Liste gelöscht.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
return $this->deleteSubdomains($candidates);
|
||||
} catch (Exception $e) {
|
||||
$this->error('Fehler beim Löschen der KAS-Subdomains: '.$e->getMessage());
|
||||
Log::error('KAS Komplett-Subdomain-Löschung fehlgeschlagen', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
private function getKasSubdomains(?string $domain): Collection
|
||||
{
|
||||
$this->info('Rufe Subdomains direkt von KAS ab...');
|
||||
|
||||
$subdomains = $this->kas->action('get_subdomains');
|
||||
|
||||
if (! is_iterable($subdomains)) {
|
||||
throw new Exception('KAS hat keine auswertbare Subdomain-Liste zurückgegeben: '.json_encode($subdomains));
|
||||
}
|
||||
|
||||
return collect($subdomains)
|
||||
->filter(function (array $subdomain) use ($domain): bool {
|
||||
$name = strtolower((string) ($subdomain['subdomain_name'] ?? ''));
|
||||
|
||||
if ($name === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $domain === null || str_ends_with($name, '.'.strtolower($domain));
|
||||
})
|
||||
->map(fn (array $subdomain): array => [
|
||||
'subdomain_name' => (string) $subdomain['subdomain_name'],
|
||||
'subdomain_path' => (string) ($subdomain['subdomain_path'] ?? ''),
|
||||
'php_version' => (string) ($subdomain['php_version'] ?? ''),
|
||||
'is_active' => (string) ($subdomain['is_active'] ?? ''),
|
||||
])
|
||||
->unique('subdomain_name')
|
||||
->sortBy('subdomain_name')
|
||||
->values();
|
||||
}
|
||||
|
||||
private function writeTemporaryList(Collection $candidates, ?string $domain, bool $isForce): string
|
||||
{
|
||||
$listFilePath = $this->resolveListFilePath();
|
||||
File::ensureDirectoryExists(dirname($listFilePath));
|
||||
|
||||
File::put($listFilePath, json_encode([
|
||||
'generated_at' => now()->toIso8601String(),
|
||||
'mode' => $isForce ? 'delete' : 'dry-run',
|
||||
'domain_filter' => $domain,
|
||||
'count' => $candidates->count(),
|
||||
'subdomains' => $candidates->values()->all(),
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
return $listFilePath;
|
||||
}
|
||||
|
||||
private function resolveListFilePath(): string
|
||||
{
|
||||
$listFilePath = (string) ($this->option('list-file') ?? '');
|
||||
|
||||
if ($listFilePath !== '') {
|
||||
return str_starts_with($listFilePath, '/') ? $listFilePath : base_path($listFilePath);
|
||||
}
|
||||
|
||||
return storage_path('app/kas-subdomain-delete-lists/kas-subdomains-'.now()->format('Ymd-His').'.json');
|
||||
}
|
||||
|
||||
private function printPreview(Collection $candidates, ?string $domain, string $listFilePath, bool $isForce): void
|
||||
{
|
||||
$this->newLine();
|
||||
$this->info('Modus: '.($isForce ? 'Löschen' : 'Dry-Run'));
|
||||
$this->info('Domain-Filter: '.($domain ?: 'keiner'));
|
||||
$this->info('Temporäre Liste: '.$listFilePath);
|
||||
$this->info('Gefundene KAS-Subdomains: '.$candidates->count());
|
||||
|
||||
if ($candidates->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['Subdomain', 'Pfad', 'PHP', 'Aktiv'],
|
||||
$candidates->take(25)->map(fn (array $candidate): array => [
|
||||
$candidate['subdomain_name'],
|
||||
$candidate['subdomain_path'] ?: '-',
|
||||
$candidate['php_version'] ?: '-',
|
||||
$candidate['is_active'] ?: '-',
|
||||
])->all()
|
||||
);
|
||||
|
||||
if ($candidates->count() > 25) {
|
||||
$this->line('... weitere '.($candidates->count() - 25).' Subdomains ausgeblendet.');
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteSubdomains(Collection $candidates): int
|
||||
{
|
||||
$deleted = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
$subdomainName = $candidate['subdomain_name'];
|
||||
$result = $this->kas->action('delete_subdomain', [
|
||||
'subdomain_name' => $subdomainName,
|
||||
]);
|
||||
|
||||
if ($this->isSuccessfulDeleteResult($result, $subdomainName)) {
|
||||
$deleted++;
|
||||
$this->info("Gelöscht: {$subdomainName}");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$failed++;
|
||||
$this->error("Fehler beim Löschen von {$subdomainName}: ".json_encode($result));
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Gelöscht: {$deleted}");
|
||||
$this->info("Fehler: {$failed}");
|
||||
|
||||
return $failed === 0 ? self::SUCCESS : self::FAILURE;
|
||||
}
|
||||
|
||||
private function isSuccessfulDeleteResult(mixed $result, string $subdomainName): bool
|
||||
{
|
||||
return $result === true
|
||||
|| $result === 'TRUE'
|
||||
|| $result === $subdomainName;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,241 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Http\Controllers\Api\KasController;
|
||||
use App\Models\UserShop;
|
||||
use Exception;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class DeletePartnerShopSubdomains extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'subdomains:delete-partnershops
|
||||
{--domain=mivita.care : Hauptdomain der Partnershop-Subdomains}
|
||||
{--start=1 : UserShop-ID, ab der Shops berücksichtigt werden}
|
||||
{--limit= : Maximale Anzahl zu löschender Subdomains}
|
||||
{--active-only : Nur aktive/nicht gelöschte UserShops berücksichtigen}
|
||||
{--include-path-matches : Zusätzlich KAS-Subdomains mit dem Partnershop-Pfad löschen}
|
||||
{--path=/mein.mivita.care/public/ : Zielpfad der alten Partnershop-Subdomains}
|
||||
{--force : Subdomains wirklich löschen; ohne diese Option läuft nur ein Dry-Run}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Löscht alte KAS-Subdomains der Partnershops nach Umstellung auf Wildcard-Konfiguration';
|
||||
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private array $protectedPrefixes = ['www.', 'api.', 'checkout.', 'preview.'];
|
||||
|
||||
public function __construct(private KasController $kas)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$domain = trim((string) $this->option('domain'), '.');
|
||||
$startId = max(1, (int) $this->option('start'));
|
||||
$limit = $this->option('limit') !== null ? (int) $this->option('limit') : null;
|
||||
$isForce = (bool) $this->option('force');
|
||||
|
||||
if ($domain === '') {
|
||||
$this->error('Die Option --domain darf nicht leer sein.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($limit !== null && $limit < 1) {
|
||||
$this->error('Die Option --limit muss größer als 0 sein.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
$subdomains = $this->getKasSubdomains();
|
||||
$shopHosts = $this->getPartnerShopHosts($domain, $startId);
|
||||
$candidates = $this->getDeletionCandidates($subdomains, $shopHosts, $domain);
|
||||
|
||||
if ($limit !== null) {
|
||||
$candidates = $candidates->take($limit)->values();
|
||||
}
|
||||
|
||||
$this->printPreview($candidates, $isForce);
|
||||
|
||||
if ($candidates->isEmpty()) {
|
||||
$this->info('Keine löschbaren Partnershop-Subdomains gefunden.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (! $isForce) {
|
||||
$this->warn('Dry-Run: Es wurde nichts gelöscht. Mit --force werden die gefundenen Subdomains gelöscht.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
return $this->deleteSubdomains($candidates);
|
||||
} catch (Exception $e) {
|
||||
$this->error('Fehler beim Löschen der Partnershop-Subdomains: '.$e->getMessage());
|
||||
Log::error('KAS Partnershop-Subdomain-Löschung fehlgeschlagen', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
private function getKasSubdomains(): Collection
|
||||
{
|
||||
$this->info('Rufe Subdomains von KAS ab...');
|
||||
|
||||
$subdomains = $this->kas->action('get_subdomains');
|
||||
|
||||
if (! is_iterable($subdomains)) {
|
||||
throw new Exception('KAS hat keine auswertbare Subdomain-Liste zurückgegeben: '.json_encode($subdomains));
|
||||
}
|
||||
|
||||
return collect($subdomains);
|
||||
}
|
||||
|
||||
private function getPartnerShopHosts(string $domain, int $startId): Collection
|
||||
{
|
||||
$query = UserShop::query()
|
||||
->where('id', '>=', $startId)
|
||||
->orderBy('id');
|
||||
|
||||
if ($this->option('active-only')) {
|
||||
$query->where('active', true);
|
||||
} else {
|
||||
$query->withTrashed();
|
||||
}
|
||||
|
||||
return $query
|
||||
->pluck('slug')
|
||||
->filter()
|
||||
->map(fn (string $slug): string => strtolower($slug.'.'.$domain))
|
||||
->unique()
|
||||
->values();
|
||||
}
|
||||
|
||||
private function getDeletionCandidates(Collection $subdomains, Collection $shopHosts, string $domain): Collection
|
||||
{
|
||||
$shopHostLookup = array_flip($shopHosts->all());
|
||||
$includePathMatches = (bool) $this->option('include-path-matches');
|
||||
$partnerShopPath = (string) $this->option('path');
|
||||
|
||||
return $subdomains
|
||||
->filter(function (array $subdomain) use ($domain, $includePathMatches, $partnerShopPath, $shopHostLookup): bool {
|
||||
$name = strtolower((string) ($subdomain['subdomain_name'] ?? ''));
|
||||
|
||||
if ($name === '' || ! $this->belongsToDomain($name, $domain) || $this->isProtectedSubdomain($name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isset($shopHostLookup[$name])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $includePathMatches
|
||||
&& trim((string) ($subdomain['subdomain_path'] ?? '')) === $partnerShopPath;
|
||||
})
|
||||
->map(fn (array $subdomain): array => [
|
||||
'subdomain_name' => (string) $subdomain['subdomain_name'],
|
||||
'subdomain_path' => (string) ($subdomain['subdomain_path'] ?? ''),
|
||||
'php_version' => (string) ($subdomain['php_version'] ?? ''),
|
||||
])
|
||||
->unique('subdomain_name')
|
||||
->sortBy('subdomain_name')
|
||||
->values();
|
||||
}
|
||||
|
||||
private function belongsToDomain(string $subdomainName, string $domain): bool
|
||||
{
|
||||
return str_ends_with($subdomainName, '.'.strtolower($domain));
|
||||
}
|
||||
|
||||
private function isProtectedSubdomain(string $subdomainName): bool
|
||||
{
|
||||
foreach ($this->protectedPrefixes as $prefix) {
|
||||
if (str_starts_with($subdomainName, $prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function printPreview(Collection $candidates, bool $isForce): void
|
||||
{
|
||||
$this->newLine();
|
||||
$this->info('Modus: '.($isForce ? 'Löschen' : 'Dry-Run'));
|
||||
$this->info('Gefundene Lösch-Kandidaten: '.$candidates->count());
|
||||
|
||||
if ($candidates->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['Subdomain', 'Pfad', 'PHP'],
|
||||
$candidates->take(25)->map(fn (array $candidate): array => [
|
||||
$candidate['subdomain_name'],
|
||||
$candidate['subdomain_path'] ?: '-',
|
||||
$candidate['php_version'] ?: '-',
|
||||
])->all()
|
||||
);
|
||||
|
||||
if ($candidates->count() > 25) {
|
||||
$this->line('... weitere '.($candidates->count() - 25).' Subdomains ausgeblendet.');
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteSubdomains(Collection $candidates): int
|
||||
{
|
||||
$deleted = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
$subdomainName = $candidate['subdomain_name'];
|
||||
$result = $this->kas->action('delete_subdomain', [
|
||||
'subdomain_name' => $subdomainName,
|
||||
]);
|
||||
|
||||
if ($this->isSuccessfulDeleteResult($result, $subdomainName)) {
|
||||
$deleted++;
|
||||
$this->info("Gelöscht: {$subdomainName}");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$failed++;
|
||||
$this->error("Fehler beim Löschen von {$subdomainName}: ".json_encode($result));
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Gelöscht: {$deleted}");
|
||||
$this->info("Fehler: {$failed}");
|
||||
|
||||
return $failed === 0 ? self::SUCCESS : self::FAILURE;
|
||||
}
|
||||
|
||||
private function isSuccessfulDeleteResult(mixed $result, string $subdomainName): bool
|
||||
{
|
||||
return $result === true
|
||||
|| $result === 'TRUE'
|
||||
|| $result === $subdomainName;
|
||||
}
|
||||
}
|
||||
72
app/Console/Commands/GdprDeleteUser.php
Normal file
72
app/Console/Commands/GdprDeleteUser.php
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Exceptions\GdprDeletionException;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Services\GdprDeletionService;
|
||||
use App\User;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class GdprDeleteUser extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'gdpr:delete
|
||||
{id : User-ID (Berater) bzw. mit --customer die ShoppingUser-ID (Endkunde)}
|
||||
{--customer : Endkunde (shopping_users) statt Berater (users) löschen}
|
||||
{--force : Sicherheitsabfrage überspringen}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'DSGVO-Löschung (Art. 17): Anonymisiert alle personenbezogenen Daten eines Beraters oder Endkunden unwiderruflich. Aufbewahrungspflichtige Daten (Rechnungen, Gutschriften, Provisionen) bleiben erhalten.';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(GdprDeletionService $gdprDeletionService): int
|
||||
{
|
||||
$id = (int) $this->argument('id');
|
||||
$isCustomer = (bool) $this->option('customer');
|
||||
|
||||
if ($isCustomer) {
|
||||
$model = ShoppingUser::withTrashed()->find($id);
|
||||
$label = $model ? 'Endkunde #'.$model->id.' ('.$model->billing_email.')' : null;
|
||||
} else {
|
||||
$model = User::withTrashed()->find($id);
|
||||
$label = $model ? 'Berater #'.$model->id.' ('.$model->email.')' : null;
|
||||
}
|
||||
|
||||
if (! $model) {
|
||||
$this->error('ERROR: Datensatz nicht gefunden, id: '.$id);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (! $this->option('force') && ! $this->confirm('DSGVO-Löschung für '.$label.' UNWIDERRUFLICH ausführen?')) {
|
||||
$this->info('Abgebrochen.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
$summary = $isCustomer
|
||||
? $gdprDeletionService->deleteShoppingUser($model)
|
||||
: $gdprDeletionService->deleteUser($model);
|
||||
|
||||
$this->info('SUCCESS: DSGVO-Löschung ausgeführt: '.json_encode($summary));
|
||||
|
||||
return 0;
|
||||
} catch (GdprDeletionException $e) {
|
||||
$this->error('ERROR: '.$e->getMessage());
|
||||
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,664 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Http\Controllers\Api\KasController;
|
||||
use App\Http\Controllers\Api\KasSLLController;
|
||||
use App\Models\UserShop;
|
||||
use Exception;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class SubDomains extends Command
|
||||
{
|
||||
/**
|
||||
* Die Signatur des Konsolenbefehls.
|
||||
*
|
||||
* Aufruf: php artisan subdomains:action --force --start=4 --debug
|
||||
* /usr/bin/php82 artisan subdomains:action --force --start=4 --create-missing --debug
|
||||
* /usr/bin/php82 artisan subdomains:action --force --debug
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'subdomains:action {user_id?} {--force} {--start=1} {--create-missing} {--debug}';
|
||||
|
||||
/**
|
||||
* Die Beschreibung des Konsolenbefehls.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Passt Parameter für die User-Subdomains an (PHP-Version, SSL)';
|
||||
|
||||
/**
|
||||
* Zeitstempel für die Ausführungszeit-Messung
|
||||
*
|
||||
* @var float
|
||||
*/
|
||||
private $timeStart;
|
||||
|
||||
/**
|
||||
* Standard-Domain für alle Subdomains
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $domain = 'mivita.care';
|
||||
|
||||
/**
|
||||
* Zu überspringende Subdomain-Präfixe
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private $skipPrefixes = ['www.', 'api.', 'checkout.', 'preview.'];
|
||||
|
||||
/**
|
||||
* Erstellt eine neue Befehlsinstanz.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt den Konsolenbefehl aus.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$this->timeStart = microtime(true);
|
||||
|
||||
$userId = $this->argument('user_id');
|
||||
$force = $this->option('force');
|
||||
$startId = $this->option('start');
|
||||
$createMissing = $this->option('create-missing');
|
||||
$debug = $this->option('debug');
|
||||
|
||||
if ($debug) {
|
||||
$this->warn("DEBUG-MODUS (DRY-RUN): Es werden keine tatsächlichen Änderungen vorgenommen!");
|
||||
}
|
||||
|
||||
$this->info("Starte Subdomain-Verwaltung" . ($force ? " (erzwungener Modus)" : ""));
|
||||
|
||||
try {
|
||||
if ($userId) {
|
||||
$this->info("Verarbeite einzelnen Benutzer mit ID: {$userId}");
|
||||
$result = $this->syncSingleUser($userId, $force, $createMissing, $debug);
|
||||
|
||||
if ($result) {
|
||||
$this->info("Benutzer {$userId} erfolgreich synchronisiert" . ($debug ? " (simuliert)" : ""));
|
||||
} else {
|
||||
$this->warn("Benutzer {$userId} konnte nicht vollständig synchronisiert werden" . ($debug ? " (simuliert)" : ""));
|
||||
}
|
||||
} else {
|
||||
$this->info("Verarbeite alle Benutzer ab ID: {$startId}");
|
||||
$result = $this->syncAllUsers($force, $startId, $createMissing, $debug);
|
||||
|
||||
// Zusammenfassung der Ergebnisse
|
||||
$this->displaySummary($result, $debug);
|
||||
}
|
||||
|
||||
$this->logExecutionTime("Subdomain-Verwaltung abgeschlossen" . ($debug ? " (DEBUG-MODUS)" : ""));
|
||||
return 0;
|
||||
} catch (Exception $e) {
|
||||
$this->error("Ein Fehler ist aufgetreten: " . $e->getMessage());
|
||||
Log::error("Shopping User Sync Fehler: ", [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
'user_id' => $userId,
|
||||
'force' => $force,
|
||||
'start_id' => $startId,
|
||||
'debug' => $debug
|
||||
]);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Zeigt eine Zusammenfassung der Synchronisationsergebnisse an
|
||||
*
|
||||
* @param array $result Ergebnisdaten der Synchronisation
|
||||
* @param bool $debug Debug-Modus (Dry-Run)
|
||||
* @return void
|
||||
*/
|
||||
private function displaySummary($result, $debug = false)
|
||||
{
|
||||
$this->line("");
|
||||
$this->info("=== Zusammenfassung " . ($debug ? "(DEBUG-MODUS)" : "") . " ===");
|
||||
$this->info("Verarbeitete Shops: " . count($result['shops']));
|
||||
$this->info("Aktualisierte PHP-Versionen: " . $result['updatedCount'] . ($debug ? " (simuliert)" : ""));
|
||||
$this->info("Aktivierte SSL-Zertifikate: " . $result['sslEnabledCount'] . ($debug ? " (simuliert)" : ""));
|
||||
$this->info("Aktualisierte SSL-Konfigurationen: " . $result['sslConfiguredCount'] . ($debug ? " (simuliert)" : ""));
|
||||
|
||||
if (!empty($result['createdSubdomains'])) {
|
||||
$this->info("Neu erstellte Subdomains: " . count($result['createdSubdomains']) . ($debug ? " (simuliert)" : ""));
|
||||
}
|
||||
|
||||
if (!empty($result['missingSubdomains'])) {
|
||||
$this->warn("Fehlende Subdomains: " . count($result['missingSubdomains']));
|
||||
}
|
||||
|
||||
if (!empty($result['unusedSubdomains'])) {
|
||||
$this->warn("Ungenutzte Subdomains: " . count($result['unusedSubdomains']));
|
||||
}
|
||||
|
||||
if (!empty($result['doubleDomains'])) {
|
||||
$this->warn("Benutzer mit mehreren Shops: " . count($result['doubleDomains']));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronisiert einen einzelnen Benutzer und seine Shops
|
||||
*
|
||||
* @param int $userId Benutzer-ID
|
||||
* @param bool $force Erzwingt die Aktualisierung aller Subdomains
|
||||
* @param bool $createMissing Erstellt fehlende Subdomains
|
||||
* @param bool $debug Debug-Modus (Dry-Run)
|
||||
* @return bool Erfolg der Operation
|
||||
*/
|
||||
private function syncSingleUser($userId, $force = false, $createMissing = false, $debug = false)
|
||||
{
|
||||
$this->info("Synchronisiere Benutzer mit ID: {$userId}");
|
||||
|
||||
// Benutzer-Shops abrufen
|
||||
$userShops = UserShop::where('user_id', $userId)->get();
|
||||
|
||||
if ($userShops->isEmpty()) {
|
||||
$this->warn("Keine Shops für Benutzer {$userId} gefunden");
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->info("Gefundene Shops für Benutzer {$userId}: " . $userShops->count());
|
||||
|
||||
// Subdomains abrufen und filtern
|
||||
$subdomains = $this->getFilteredSubdomains();
|
||||
$success = true;
|
||||
|
||||
// Benutzer-Shops durchlaufen und mit Subdomains abgleichen
|
||||
foreach ($userShops as $userShop) {
|
||||
$fullDomainName = $userShop->slug . '.' . $this->domain;
|
||||
$this->info("Verarbeite Shop: {$fullDomainName}");
|
||||
|
||||
// Prüfen, ob Subdomain existiert
|
||||
if (array_key_exists($fullDomainName, $subdomains)) {
|
||||
$success = $this->processExistingSubdomain($userShop, $subdomains[$fullDomainName], $force, $debug) && $success;
|
||||
} else {
|
||||
// Subdomain fehlt
|
||||
$this->warn("Shop {$userShop->slug}: Keine Subdomain gefunden");
|
||||
|
||||
// Optional: Neue Subdomain erstellen
|
||||
if ($createMissing) {
|
||||
$this->info("Erstelle fehlende Subdomain für Shop {$userShop->slug}" . ($debug ? " (simuliert)" : ""));
|
||||
$success = $this->createSubdomain($userShop->slug, $debug) && $success;
|
||||
} else {
|
||||
$success = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verarbeitet eine existierende Subdomain und aktualisiert sie bei Bedarf
|
||||
*
|
||||
* @param UserShop $userShop Shop-Objekt
|
||||
* @param array $subdomainInfo Subdomain-Informationen
|
||||
* @param bool $force Erzwingt die Aktualisierung
|
||||
* @param bool $debug Debug-Modus (Dry-Run)
|
||||
* @return bool Erfolg der Operation
|
||||
*/
|
||||
private function processExistingSubdomain($userShop, $subdomainInfo, $force, $debug = false)
|
||||
{
|
||||
$success = true;
|
||||
$hasSSL = ($subdomainInfo['ssl_certificate_sni'] === 'Y' ||
|
||||
$subdomainInfo['ssl_proxy'] === 'Y');
|
||||
$sslActive = ($subdomainInfo['ssl_certificate_sni_is_active'] ?? 'N') === 'Y';
|
||||
$phpVersion = $subdomainInfo['php_version'];
|
||||
|
||||
$this->info("Shop {$userShop->slug}: PHP-Version: {$phpVersion}, SSL: " .
|
||||
($hasSSL ? "Aktiviert" : "Nicht aktiviert") .
|
||||
($hasSSL ? ", SSL aktiv: " . ($sslActive ? "Ja" : "Nein") : ""));
|
||||
|
||||
// Prüfen, ob PHP-Version aktualisiert werden muss
|
||||
$requiredPhpVersion = config('app.php_version');
|
||||
if ($force || $phpVersion !== $requiredPhpVersion) {
|
||||
$this->info("Shop {$userShop->slug}: PHP-Version aktualisieren von {$phpVersion} auf {$requiredPhpVersion}" . ($debug ? " (simuliert)" : ""));
|
||||
|
||||
if (!$debug && !$this->updateSubdomainParams($userShop->slug, $requiredPhpVersion)) {
|
||||
$this->error("PHP-Version für {$userShop->slug}.{$this->domain} konnte nicht aktualisiert werden");
|
||||
$success = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Prüfen, ob SSL aktiviert werden muss
|
||||
if ($force || !$hasSSL) {
|
||||
$this->info("Shop {$userShop->slug}: SSL aktivieren" . ($debug ? " (simuliert)" : ""));
|
||||
|
||||
if (!$debug && !$this->enableSSL($userShop->slug)) {
|
||||
$this->error("SSL für {$userShop->slug}.{$this->domain} konnte nicht aktiviert werden");
|
||||
$success = false;
|
||||
}
|
||||
}
|
||||
// Prüfen, ob SSL-Konfiguration aktualisiert werden muss
|
||||
else if ($force || ($hasSSL && !$sslActive)) {
|
||||
$this->info("Shop {$userShop->slug}: SSL-Konfiguration aktualisieren" . ($debug ? " (simuliert)" : ""));
|
||||
|
||||
if (!$debug && !$this->updateSSL($userShop->slug . '.' . $this->domain)) {
|
||||
$this->error("SSL-Konfiguration für {$userShop->slug}.{$this->domain} konnte nicht aktualisiert werden");
|
||||
$success = false;
|
||||
}
|
||||
}
|
||||
|
||||
return $success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronisiert alle Benutzer-Shops
|
||||
*
|
||||
* @param bool $force Erzwingt die Aktualisierung aller Subdomains
|
||||
* @param int $startId Beginnt die Synchronisation ab dieser ID
|
||||
* @param bool $createMissing Erstellt fehlende Subdomains
|
||||
* @param bool $debug Debug-Modus (Dry-Run)
|
||||
* @return array Ergebnisdaten der Synchronisation
|
||||
*/
|
||||
private function syncAllUsers($force, $startId, $createMissing = false, $debug = false)
|
||||
{
|
||||
$this->info("Starte Synchronisation aller Benutzer-Shops ab ID: {$startId}");
|
||||
|
||||
// Benutzer-Shops abrufen
|
||||
$userShopsQuery = UserShop::query();
|
||||
|
||||
if ($startId > 1) {
|
||||
$userShopsQuery->where('id', '>=', $startId);
|
||||
}
|
||||
|
||||
$userShops = $userShopsQuery->limit(1000)->get();
|
||||
$this->info("Gefundene Benutzer-Shops: " . $userShops->count());
|
||||
|
||||
// Subdomains abrufen und filtern
|
||||
$subdomains = $this->getFilteredSubdomains();
|
||||
$this->info("Gefilterte Subdomains: " . count($subdomains));
|
||||
|
||||
// Ergebnis-Arrays initialisieren
|
||||
$doubleDomains = [];
|
||||
$missingSubdomains = [];
|
||||
$outdatedPhpVersions = [];
|
||||
$missingSSL = [];
|
||||
$sslConfigurationNeeded = [];
|
||||
$createdSubdomains = [];
|
||||
$updatedCount = 0;
|
||||
$sslEnabledCount = 0;
|
||||
$sslConfiguredCount = 0;
|
||||
|
||||
// Benutzer-Shops durchlaufen und mit Subdomains abgleichen
|
||||
foreach ($userShops as $userShop) {
|
||||
$fullDomainName = $userShop->slug . '.' . $this->domain;
|
||||
|
||||
// Status der Subdomain setzen
|
||||
$userShop->hasSubdomain = false;
|
||||
$userShop->hasSSL = false;
|
||||
$userShop->sslActive = false;
|
||||
$userShop->PHPversion = "";
|
||||
|
||||
// Prüfen, ob Subdomain existiert
|
||||
if (array_key_exists($fullDomainName, $subdomains)) {
|
||||
$userShop->hasSubdomain = true;
|
||||
$userShop->hasSSL = ($subdomains[$fullDomainName]['ssl_certificate_sni'] === 'Y' ||
|
||||
$subdomains[$fullDomainName]['ssl_proxy'] === 'Y');
|
||||
$userShop->sslActive = ($subdomains[$fullDomainName]['ssl_certificate_sni_is_active'] ?? 'N') === 'Y';
|
||||
$userShop->PHPversion = $subdomains[$fullDomainName]['php_version'];
|
||||
|
||||
// Prüfen, ob PHP-Version aktualisiert werden muss
|
||||
$requiredPhpVersion = config('app.php_version');
|
||||
if ($force || $userShop->PHPversion !== $requiredPhpVersion) {
|
||||
$this->info("Shop {$userShop->slug}: PHP-Version aktualisieren von {$userShop->PHPversion} auf {$requiredPhpVersion}" . ($debug ? " (simuliert)" : ""));
|
||||
$outdatedPhpVersions[] = $userShop->slug;
|
||||
|
||||
if (!$debug && $this->updateSubdomainParams($userShop->slug, $requiredPhpVersion)) {
|
||||
$updatedCount++;
|
||||
} else if ($debug) {
|
||||
// Im Debug-Modus zählen wir trotzdem, als ob es erfolgreich wäre
|
||||
$updatedCount++;
|
||||
}
|
||||
}else{
|
||||
$this->info("Shop {$userShop->slug}: PHP-Version ist aktuell: {$userShop->PHPversion}");
|
||||
}
|
||||
|
||||
// Prüfen, ob SSL aktiviert werden muss
|
||||
/* if ($force || !$userShop->hasSSL) {
|
||||
$this->info("Shop {$userShop->slug}: SSL aktivieren" . ($debug ? " (simuliert)" : ""));
|
||||
$missingSSL[] = $userShop->slug;
|
||||
|
||||
if (!$debug && $this->enableSSL($userShop->slug)) {
|
||||
$sslEnabledCount++;
|
||||
} else if ($debug) {
|
||||
// Im Debug-Modus zählen wir trotzdem, als ob es erfolgreich wäre
|
||||
$sslEnabledCount++;
|
||||
}
|
||||
}
|
||||
// Prüfen, ob SSL-Konfiguration aktualisiert werden muss
|
||||
else if ($force || ($userShop->hasSSL && !$userShop->sslActive)) {
|
||||
$this->info("Shop {$userShop->slug}: SSL-Konfiguration aktualisieren" . ($debug ? " (simuliert)" : ""));
|
||||
$sslConfigurationNeeded[] = $userShop->slug;
|
||||
|
||||
if (!$debug && $this->updateSSL($fullDomainName)) {
|
||||
$sslConfiguredCount++;
|
||||
} else if ($debug) {
|
||||
// Im Debug-Modus zählen wir trotzdem, als ob es erfolgreich wäre
|
||||
$sslConfiguredCount++;
|
||||
}
|
||||
}*/
|
||||
|
||||
// Subdomain aus der Liste entfernen, um später ungenutzte zu identifizieren
|
||||
unset($subdomains[$fullDomainName]);
|
||||
} else {
|
||||
// Subdomain fehlt
|
||||
$missingSubdomains[] = $userShop->slug;
|
||||
$this->warn("Shop {$userShop->slug}: Keine Subdomain gefunden");
|
||||
|
||||
// Optional: Neue Subdomain erstellen
|
||||
if ($createMissing) {
|
||||
$this->info("Erstelle fehlende Subdomain für Shop {$userShop->slug}" . ($debug ? " (simuliert)" : ""));
|
||||
if (!$debug && $this->createSubdomain($userShop->slug)) {
|
||||
$createdSubdomains[] = $userShop->slug;
|
||||
} else if ($debug) {
|
||||
// Im Debug-Modus zählen wir trotzdem, als ob es erfolgreich wäre
|
||||
$createdSubdomains[] = $userShop->slug;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Doppelte Domains pro Benutzer erfassen
|
||||
$doubleDomains[$userShop->user_id][$userShop->id] = $fullDomainName;
|
||||
}
|
||||
|
||||
// Bereinigen der doppelten Domains (nur Benutzer mit mehreren Shops behalten)
|
||||
foreach ($doubleDomains as $userId => $domains) {
|
||||
if (count($domains) === 1) {
|
||||
unset($doubleDomains[$userId]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->logExecutionTime("Synchronisation abgeschlossen" . ($debug ? " (DEBUG-MODUS)" : ""));
|
||||
|
||||
// Ergebnisdaten zurückgeben
|
||||
return [
|
||||
'shops' => $userShops,
|
||||
'unusedSubdomains' => $subdomains,
|
||||
'doubleDomains' => $doubleDomains,
|
||||
'missingSubdomains' => $missingSubdomains,
|
||||
'outdatedPhpVersions' => $outdatedPhpVersions,
|
||||
'missingSSL' => $missingSSL,
|
||||
'sslConfigurationNeeded' => $sslConfigurationNeeded,
|
||||
'createdSubdomains' => $createdSubdomains,
|
||||
'updatedCount' => $updatedCount,
|
||||
'sslEnabledCount' => $sslEnabledCount,
|
||||
'sslConfiguredCount' => $sslConfiguredCount
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruft alle Subdomains ab und filtert sie
|
||||
*
|
||||
* @return array Gefilterte Subdomains
|
||||
*/
|
||||
private function getFilteredSubdomains()
|
||||
{
|
||||
$kas = new KasController();
|
||||
$subdomains = [];
|
||||
|
||||
// Alle Subdomains abrufen
|
||||
$this->info("Rufe Subdomains von KAS ab...");
|
||||
$getSubdomains = $kas->action('get_subdomains');
|
||||
|
||||
// Subdomains filtern und in ein leicht zugängliches Array umwandeln
|
||||
foreach ($getSubdomains as $subdomain) {
|
||||
if (!isset($subdomain['subdomain_name'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Spezielle Subdomains überspringen
|
||||
$skip = false;
|
||||
foreach ($this->skipPrefixes as $prefix) {
|
||||
if (strpos($subdomain['subdomain_name'], $prefix) !== false) {
|
||||
$skip = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($skip) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Subdomain-Informationen speichern
|
||||
$subdomains[$subdomain['subdomain_name']] = [
|
||||
'ssl_certificate_sni' => $subdomain['ssl_certificate_sni'] ?? 'N',
|
||||
'php_version' => $subdomain['php_version'] ?? '',
|
||||
'ssl_proxy' => $subdomain['ssl_proxy'] ?? 'N',
|
||||
'ssl_certificate_sni_is_active' => $subdomain['ssl_certificate_sni_is_active'] ?? 'N',
|
||||
];
|
||||
}
|
||||
|
||||
return $subdomains;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ändert Parameter einer Subdomain, insbesondere die PHP-Version
|
||||
*
|
||||
* @param string $subdomain Name der Subdomain ohne Domain
|
||||
* @param string $phpVersion Neue PHP-Version (z.B. '8.2')
|
||||
* @param array $additionalParams Weitere zu ändernde Parameter
|
||||
* @param bool $debug Debug-Modus (Dry-Run)
|
||||
* @return bool Erfolg der Operation
|
||||
*/
|
||||
private function updateSubdomainParams($subdomain, $phpVersion, $additionalParams = [], $debug = false)
|
||||
{
|
||||
$this->info("Aktualisiere Parameter für: {$subdomain}.{$this->domain}" . ($debug ? " (simuliert)" : ""));
|
||||
|
||||
if ($debug) {
|
||||
$this->line(" - PHP-Version: {$phpVersion}");
|
||||
if (!empty($additionalParams)) {
|
||||
$this->line(" - Zusätzliche Parameter: " . json_encode($additionalParams));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
$kas = new KasController();
|
||||
|
||||
// Standardparameter
|
||||
$params = [
|
||||
'subdomain_name' => $subdomain . '.' . $this->domain, // Vollständigen Domainnamen verwenden
|
||||
'php_version' => $phpVersion
|
||||
];
|
||||
|
||||
// Oder alternativ, falls die API die Subdomain und Domain getrennt erwartet:
|
||||
// $params = [
|
||||
// 'subdomain_name' => $subdomain,
|
||||
// 'domain_name' => $this->domain,
|
||||
// 'php_version' => $phpVersion
|
||||
// ];
|
||||
|
||||
// Zusätzliche Parameter hinzufügen
|
||||
$params = array_merge($params, $additionalParams);
|
||||
|
||||
// Subdomain aktualisieren
|
||||
$result = $kas->action('update_subdomain', $params);
|
||||
$this->info("Parameter: ".json_encode($params));
|
||||
|
||||
if ($result) {
|
||||
$this->info("Parameter für {$subdomain}.{$this->domain} erfolgreich aktualisiert " . json_encode($result));
|
||||
return true;
|
||||
} else {
|
||||
$this->error("Fehler beim Aktualisieren der Parameter für {$subdomain}.{$this->domain}: " . json_encode($result));
|
||||
return false;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$this->error("Fehler beim Aktualisieren der Parameter für {$subdomain}.{$this->domain}: " . $e->getMessage());
|
||||
Log::error("Subdomain Parameter Update Fehler", [
|
||||
'subdomain' => $subdomain,
|
||||
'domain' => $this->domain,
|
||||
'php_version' => $phpVersion,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualisiert die SSL-Konfiguration einer Subdomain mit erweiterten Parametern
|
||||
*
|
||||
* @param string $subdomainName Vollständiger Domainname (z.B. 'shop.mivita.care')
|
||||
* @param bool $debug Debug-Modus (Dry-Run)
|
||||
* @return bool Erfolg der Operation
|
||||
*/
|
||||
private function updateSSL($subdomainName, $debug = false)
|
||||
{
|
||||
$this->info("Aktualisiere SSL-Konfiguration für: {$subdomainName}" . ($debug ? " (simuliert)" : ""));
|
||||
|
||||
if ($debug) {
|
||||
$this->line(" - SSL-Parameter werden aktualisiert");
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
$kas = new KasController();
|
||||
$ssl = KasSLLController::getApiSSLParameter();
|
||||
|
||||
$params = array_merge(['hostname' => $subdomainName], $ssl);
|
||||
$result = $kas->action('update_ssl', $params);
|
||||
|
||||
if ($result === "TRUE" || $result === true) {
|
||||
$this->info("SSL-Konfiguration für {$subdomainName} erfolgreich aktualisiert");
|
||||
return true;
|
||||
} else {
|
||||
$this->warn("SSL-Konfiguration für {$subdomainName} nicht vollständig aktualisiert: " . json_encode($result));
|
||||
return false;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$this->error("Fehler bei der SSL-Konfiguration für {$subdomainName}: " . $e->getMessage());
|
||||
Log::error("SSL Update Fehler", [
|
||||
'subdomain' => $subdomainName,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktiviert SSL für eine Subdomain mit vollständiger Konfiguration
|
||||
*
|
||||
* @param string $subdomain Name der Subdomain ohne Domain
|
||||
* @param bool $debug Debug-Modus (Dry-Run)
|
||||
* @return bool Erfolg der Operation
|
||||
*/
|
||||
private function enableSSL($subdomain, $debug = false)
|
||||
{
|
||||
$fullDomainName = $subdomain . '.' . $this->domain;
|
||||
$this->info("Aktiviere SSL für: {$fullDomainName}" . ($debug ? " (simuliert)" : ""));
|
||||
|
||||
if ($debug) {
|
||||
$this->line(" - SSL-Proxy wird aktiviert");
|
||||
$this->line(" - HTTP zu HTTPS Weiterleitung wird eingerichtet");
|
||||
$this->line(" - SSL-Konfiguration wird aktualisiert");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Schritt 1: Subdomain-Parameter aktualisieren (ssl_proxy aktivieren)
|
||||
$subdomainResult = $this->updateSubdomainParams($subdomain, config('app.php_version'), [
|
||||
'ssl_proxy' => 'Y',
|
||||
'redirect_status' => 301 // Weiterleitung von HTTP auf HTTPS
|
||||
]);
|
||||
|
||||
if (!$subdomainResult) {
|
||||
$this->error("SSL-Aktivierung für {$fullDomainName} fehlgeschlagen: Subdomain-Parameter konnten nicht aktualisiert werden");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Schritt 2: SSL-Konfiguration aktualisieren
|
||||
$sslResult = $this->updateSSL($fullDomainName);
|
||||
|
||||
if (!$sslResult) {
|
||||
$this->warn("SSL-Aktivierung für {$fullDomainName} teilweise erfolgreich: SSL-Konfiguration konnte nicht aktualisiert werden");
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->info("SSL für {$fullDomainName} vollständig aktiviert");
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt eine neue Subdomain für einen Shop
|
||||
*
|
||||
* @param string $slug Shop-Slug
|
||||
* @param bool $debug Debug-Modus (Dry-Run)
|
||||
* @return bool Erfolg der Operation
|
||||
*/
|
||||
private function createSubdomain($slug, $debug = false)
|
||||
{
|
||||
$fullDomainName = $slug . '.' . $this->domain;
|
||||
$this->info("Erstelle neue Subdomain: {$fullDomainName}" . ($debug ? " (simuliert)" : ""));
|
||||
|
||||
if ($debug) {
|
||||
$this->line(" - Pfad: /mein.mivita.care/public/");
|
||||
$this->line(" - PHP-Version: " . config('app.php_version'));
|
||||
$this->line(" - SSL wird direkt aktiviert");
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
$kas = new KasController();
|
||||
|
||||
// Parameter für die neue Subdomain
|
||||
$params = [
|
||||
'subdomain_name' => $slug,
|
||||
'domain_name' => $this->domain,
|
||||
'subdomain_path' => '/mein.mivita.care/public/',
|
||||
'php_version' => config('app.php_version'),
|
||||
];
|
||||
|
||||
// Subdomain erstellen
|
||||
$result = $kas->action('add_subdomain', $params);
|
||||
|
||||
if ($result === $fullDomainName) {
|
||||
$this->info("Subdomain {$fullDomainName} erfolgreich erstellt");
|
||||
|
||||
// SSL direkt aktivieren
|
||||
$this->enableSSL($slug);
|
||||
|
||||
return true;
|
||||
} else {
|
||||
$this->error("Fehler beim Erstellen der Subdomain {$fullDomainName}: " . json_encode($result));
|
||||
return false;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$this->error("Fehler beim Erstellen der Subdomain {$fullDomainName}: " . $e->getMessage());
|
||||
Log::error("Subdomain Erstellung Fehler", [
|
||||
'slug' => $slug,
|
||||
'domain' => $this->domain,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Protokolliert die Ausführungszeit einer Operation
|
||||
*
|
||||
* @param string $message Nachricht für die Protokollierung
|
||||
* @return void
|
||||
*/
|
||||
private function logExecutionTime($message)
|
||||
{
|
||||
$diff = microtime(true) - $this->timeStart;
|
||||
$sec = intval($diff);
|
||||
$micro = $diff - $sec;
|
||||
|
||||
$this->info($message. ' | Time: '.$sec. 'sec :' . round($micro * 1000, 4) . " ms");
|
||||
}
|
||||
}
|
||||
11
app/Exceptions/GdprDeletionException.php
Normal file
11
app/Exceptions/GdprDeletionException.php
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* Wird geworfen, wenn eine DSGVO-Löschung nicht ausgeführt werden kann
|
||||
* (z. B. Admin-Account, aktives Abo, Datensatz gehört zu einem Berater).
|
||||
*/
|
||||
class GdprDeletionException extends Exception {}
|
||||
|
|
@ -117,6 +117,7 @@ class AboController extends Controller
|
|||
if (isset($data['action'])) {
|
||||
if ($data['action'] === 'abo_update_settings') {
|
||||
$user_abo = UserAbo::findOrFail($data['id']);
|
||||
$data['view'] = 'admin';
|
||||
$this->aboRepository->setModel($user_abo);
|
||||
$this->aboRepository->update($data);
|
||||
|
||||
|
|
@ -274,7 +275,7 @@ class AboController extends Controller
|
|||
->orderColumn('status', 'status $1')
|
||||
->orderColumn('active', 'active $1')
|
||||
->orderColumn('is_for', 'is_for $1')
|
||||
->orderColumn('count', 'count $1')
|
||||
->orderColumn('count', '(select count(*) from user_abo_orders where user_abo_orders.user_abo_id = user_abos.id and user_abo_orders.status >= 2) $1')
|
||||
->orderColumn('amount', 'amount $1')
|
||||
->orderColumn('payone_userid', 'payone_userid $1')
|
||||
->rawColumns(['id', 'status', 'active', 'is_for', 'member'])
|
||||
|
|
|
|||
|
|
@ -2,21 +2,23 @@
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
|
||||
|
||||
use Auth;
|
||||
use Carbon;
|
||||
use Request;
|
||||
use App\User;
|
||||
use Validator;
|
||||
use App\Exceptions\GdprDeletionException;
|
||||
use App\Mail\UserRestoreEmail;
|
||||
use App\Models\UserAccount;
|
||||
use App\Repositories\UserRepository;
|
||||
use App\Services\GdprDeletionService;
|
||||
use App\Services\HTMLHelper;
|
||||
use App\Services\SysLog;
|
||||
use App\Services\UserUtil;
|
||||
use App\Models\UserAccount;
|
||||
use App\Services\HTMLHelper;
|
||||
use App\Models\PaymentMethod;
|
||||
use App\Repositories\UserRepository;
|
||||
use App\User;
|
||||
use Auth;
|
||||
use Carbon;
|
||||
use Illuminate\Contracts\View\Factory;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Routing\Redirector;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Util;
|
||||
use Illuminate\View\View;
|
||||
use Request;
|
||||
|
||||
class AdminUserController extends Controller
|
||||
{
|
||||
|
|
@ -29,33 +31,35 @@ class AdminUserController extends Controller
|
|||
}
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
|
||||
* @return Factory|View
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$data = [
|
||||
//'values' => User::where('admin', 0)->get(),
|
||||
'values' => User::where('confirmation_code_remider', '!=', 2)->get(),
|
||||
// 'values' => User::where('admin', 0)->get(),
|
||||
'values' => User::where('confirmation_code_remider', '!=', 2)->get(),
|
||||
];
|
||||
|
||||
return view('admin.user.index', $data);
|
||||
}
|
||||
|
||||
public function edit($user_id)
|
||||
{
|
||||
$user = User::findOrFail($user_id);
|
||||
if (!$user->account) {
|
||||
$user->account = new UserAccount();
|
||||
if (! $user->account) {
|
||||
$user->account = new UserAccount;
|
||||
}
|
||||
|
||||
$data = [
|
||||
'user' => $user,
|
||||
];
|
||||
|
||||
return view('admin.user.edit', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Request $request
|
||||
* @return \Illuminate\Contracts\View\Factory|\Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector|\Illuminate\View\View
|
||||
* @param Request $request
|
||||
* @return Factory|RedirectResponse|Redirector|View
|
||||
*/
|
||||
public function store()
|
||||
{
|
||||
|
|
@ -73,7 +77,7 @@ class AdminUserController extends Controller
|
|||
SysLog::action('save-admin', 'admin_user', 3)
|
||||
->setUserId(Auth::user()->id)
|
||||
->setModel($user->id, User::class)
|
||||
->setMessage('Set user admin value: ' . HTMLHelper::getLabel($user->admin))
|
||||
->setMessage('Set user admin value: '.HTMLHelper::getLabel($user->admin))
|
||||
->save();
|
||||
}
|
||||
|
||||
|
|
@ -81,10 +85,10 @@ class AdminUserController extends Controller
|
|||
$data['confirmed'] = isset($data['confirmed']) ? true : false;
|
||||
$user->confirmed = $data['confirmed'];
|
||||
if ($data['confirmed']) {
|
||||
if (!isset($data['confirmation_date']) || $data['confirmation_date'] == "") {
|
||||
if (! isset($data['confirmation_date']) || $data['confirmation_date'] == '') {
|
||||
$user->confirmation_date = now();
|
||||
} else {
|
||||
$user->confirmation_date = \Carbon::parse(str_replace("- ", "", $data['confirmation_date']));
|
||||
$user->confirmation_date = Carbon::parse(str_replace('- ', '', $data['confirmation_date']));
|
||||
}
|
||||
} else {
|
||||
$user->confirmation_date = null;
|
||||
|
|
@ -92,7 +96,7 @@ class AdminUserController extends Controller
|
|||
SysLog::action('save-confirmed', 'admin_user', 3)
|
||||
->setUserId(Auth::user()->id)
|
||||
->setModel($user->id, User::class)
|
||||
->setMessage('Set user confirmed value: ' . $user->confirmed . " to date: " . $data['confirmation_date'])
|
||||
->setMessage('Set user confirmed value: '.$user->confirmed.' to date: '.$data['confirmation_date'])
|
||||
->save();
|
||||
}
|
||||
|
||||
|
|
@ -103,10 +107,10 @@ class AdminUserController extends Controller
|
|||
$user->wizard = 20;
|
||||
}
|
||||
if ($data['active']) {
|
||||
if (!isset($data['active_date']) || $data['active_date'] == "") {
|
||||
if (! isset($data['active_date']) || $data['active_date'] == '') {
|
||||
$user->active_date = now();
|
||||
} else {
|
||||
$user->active_date = \Carbon::parse(str_replace("- ", "", $data['active_date']));
|
||||
$user->active_date = Carbon::parse(str_replace('- ', '', $data['active_date']));
|
||||
}
|
||||
} else {
|
||||
$user->active_date = null;
|
||||
|
|
@ -114,7 +118,7 @@ class AdminUserController extends Controller
|
|||
SysLog::action('save-active', 'admin_user', 3)
|
||||
->setUserId(Auth::user()->id)
|
||||
->setModel($user->id, User::class)
|
||||
->setMessage('Set user active value: ' . $user->active . " to date: " . $data['active_date'])
|
||||
->setMessage('Set user active value: '.$user->active.' to date: '.$data['active_date'])
|
||||
->save();
|
||||
}
|
||||
|
||||
|
|
@ -122,14 +126,15 @@ class AdminUserController extends Controller
|
|||
$restore_account = isset($data['restore_account']) ? true : false;
|
||||
$restore_childs = isset($data['restore_childs']) ? true : false;
|
||||
$restore_send_email = isset($data['restore_send_email']) ? true : false;
|
||||
if (isset($data['payment_account']) || $data['payment_account'] != "") {
|
||||
if (isset($data['payment_account']) || $data['payment_account'] != '') {
|
||||
|
||||
$error = UserUtil::checkEmailExists($user);
|
||||
if ($error) {
|
||||
\Session()->flash('alert-error', $error);
|
||||
|
||||
return redirect()->back()->withInput()->withErrors(['error' => $error]);
|
||||
}
|
||||
$payment_account = \Carbon::parse(str_replace("- ", "", $data['payment_account']));
|
||||
$payment_account = Carbon::parse(str_replace('- ', '', $data['payment_account']));
|
||||
if ($restore_account) {
|
||||
UserUtil::restoreUser($user, $payment_account);
|
||||
if ($restore_childs) {
|
||||
|
|
@ -137,24 +142,24 @@ class AdminUserController extends Controller
|
|||
}
|
||||
if ($restore_send_email) {
|
||||
$user = User::with('account')->findOrFail($data['id']);
|
||||
Mail::to($user->email)->send(new \App\Mail\UserRestoreEmail($user));
|
||||
Mail::to($user->email)->send(new UserRestoreEmail($user));
|
||||
}
|
||||
}
|
||||
}
|
||||
SysLog::action('save-restore', 'admin_user', 3)
|
||||
->setUserId(Auth::user()->id)
|
||||
->setModel($user->id, User::class)
|
||||
->setMessage('Set user restore_account value: ' . $user->restore_account . " and restore_childs value: " . $user->restore_childs)
|
||||
->setMessage('Set user restore_account value: '.$user->restore_account.' and restore_childs value: '.$user->restore_childs)
|
||||
->save();
|
||||
}
|
||||
|
||||
if (isset($data['save-account'])) {
|
||||
$old = $user->getPaymentAccountDateFormat(true);
|
||||
if (!isset($data['payment_account']) || $data['payment_account'] == "") {
|
||||
if (! isset($data['payment_account']) || $data['payment_account'] == '') {
|
||||
$user->payment_account = null;
|
||||
} else {
|
||||
$user->wizard = 100;
|
||||
$payment_account = \Carbon::parse(str_replace("- ", "", $data['payment_account']));
|
||||
$payment_account = Carbon::parse(str_replace('- ', '', $data['payment_account']));
|
||||
$user->payment_account = $payment_account;
|
||||
if ($payment_account > Carbon::now()) {
|
||||
if ($user->active === 0) {
|
||||
|
|
@ -168,26 +173,26 @@ class AdminUserController extends Controller
|
|||
}
|
||||
}
|
||||
}
|
||||
//th.schifferegger@gmail.com
|
||||
// th.schifferegger@gmail.com
|
||||
SysLog::action('save-account', 'admin_user', 3)
|
||||
->setUserId(Auth::user()->id)
|
||||
->setModel($user->id, User::class)
|
||||
->setMessage('Set user payment_account from date: ' . $old . " to date: " . $data['payment_account'])
|
||||
->setMessage('Set user payment_account from date: '.$old.' to date: '.$data['payment_account'])
|
||||
->save();
|
||||
}
|
||||
|
||||
if (isset($data['save-shop'])) {
|
||||
$old = $user->getPaymentShopDateFormat(true);
|
||||
if (!isset($data['payment_shop']) || $data['payment_shop'] == "") {
|
||||
if (! isset($data['payment_shop']) || $data['payment_shop'] == '') {
|
||||
$user->payment_shop = null;
|
||||
} else {
|
||||
$user->wizard = 100;
|
||||
$user->payment_shop = \Carbon::parse(str_replace("- ", "", $data['payment_shop']));
|
||||
$user->payment_shop = Carbon::parse(str_replace('- ', '', $data['payment_shop']));
|
||||
}
|
||||
SysLog::action('save-shop', 'admin_user', 3)
|
||||
->setUserId(Auth::user()->id)
|
||||
->setModel($user->id, User::class)
|
||||
->setMessage('Set user payment_shop from date: ' . $old . " to date: " . $data['payment_shop'])
|
||||
->setMessage('Set user payment_shop from date: '.$old.' to date: '.$data['payment_shop'])
|
||||
->save();
|
||||
}
|
||||
|
||||
|
|
@ -196,7 +201,7 @@ class AdminUserController extends Controller
|
|||
SysLog::action('save-test_mode', 'admin_user', 3)
|
||||
->setUserId(Auth::user()->id)
|
||||
->setModel($user->id, User::class)
|
||||
->setMessage('Set user test_mode value: ' . $user->test_mode)
|
||||
->setMessage('Set user test_mode value: '.$user->test_mode)
|
||||
->save();
|
||||
}
|
||||
|
||||
|
|
@ -205,13 +210,14 @@ class AdminUserController extends Controller
|
|||
SysLog::action('save-payment_methods', 'admin_user', 3)
|
||||
->setUserId(Auth::user()->id)
|
||||
->setModel($user->id, User::class)
|
||||
->setMessage('Set user payment_methods value: ' . $user->getPaymentMethodsShort())
|
||||
->setMessage('Set user payment_methods value: '.$user->getPaymentMethodsShort())
|
||||
->save();
|
||||
}
|
||||
|
||||
$user->save();
|
||||
|
||||
\Session()->flash('alert-save', true);
|
||||
|
||||
return redirect('/admin/users');
|
||||
}
|
||||
|
||||
|
|
@ -228,15 +234,24 @@ class AdminUserController extends Controller
|
|||
$this->userRepo->deleteUser($user, true);
|
||||
\Session()->flash('alert-success', __('msg.contact_delete'));
|
||||
}
|
||||
if (isset($data['gdpr_delete_user'])) {
|
||||
try {
|
||||
app(GdprDeletionService::class)->deleteUser($user, Auth::id());
|
||||
\Session()->flash('alert-success', __('msg.gdpr_delete_success'));
|
||||
} catch (GdprDeletionException $e) {
|
||||
\Session()->flash('alert-danger', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return redirect('/admin/users');
|
||||
}
|
||||
|
||||
|
||||
public function userLoginAs($userId)
|
||||
{
|
||||
if (Auth::user()->isSuperAdmin()) {
|
||||
$user = User::find($userId);
|
||||
Auth::login($user);
|
||||
|
||||
return redirect('/home');
|
||||
}
|
||||
}
|
||||
|
|
@ -253,7 +268,7 @@ class AdminUserController extends Controller
|
|||
})
|
||||
->with('account')
|
||||
->select('users.*')
|
||||
->where('users.admin', "<", 5);
|
||||
->where('users.admin', '<', 5);
|
||||
|
||||
return \DataTables::eloquent($query)
|
||||
->addColumn('first_name', function (User $user) {
|
||||
|
|
@ -261,89 +276,101 @@ class AdminUserController extends Controller
|
|||
})
|
||||
->addColumn('email', function (User $user) {
|
||||
if ($user->pre_deleted_at) {
|
||||
return '<span class="badge badge-pill badge-danger">' . $user->email . '</span>';
|
||||
return '<span class="badge badge-pill badge-danger">'.$user->email.'</span>';
|
||||
}
|
||||
|
||||
return $user->email;
|
||||
})
|
||||
->addColumn('last_name', function (User $user) {
|
||||
return $user->account ? $user->account->last_name : '';
|
||||
})
|
||||
->addColumn('id', function (User $user) {
|
||||
return '<a href="' . route('admin_lead_edit', [$user->id]) . '" class="btn icon-btn btn-sm btn-primary"><span class="fa fa-edit"></span></a>';
|
||||
return '<a href="'.route('admin_lead_edit', [$user->id]).'" class="btn icon-btn btn-sm btn-primary"><span class="fa fa-edit"></span></a>';
|
||||
})
|
||||
->addColumn('admin', function (User $user) {
|
||||
return '<a href="#" data-toggle="modal" data-target="#modals-admin" data-id="' . $user->id . '" data-email="' . $user->email . '" data-admin="' . $user->admin . '">' . HTMLHelper::getRoleLabel($user->admin) . '</a>';
|
||||
return '<a href="#" data-toggle="modal" data-target="#modals-admin" data-id="'.$user->id.'" data-email="'.$user->email.'" data-admin="'.$user->admin.'">'.HTMLHelper::getRoleLabel($user->admin).'</a>';
|
||||
})
|
||||
->addColumn('confirmed', function (User $user) {
|
||||
$date = $user->getConfirmationDateFormat();
|
||||
$link = '<a href="#" data-toggle="modal" data-target="#modals-confirmed" data-id="' . $user->id . '" data-email="' . $user->email . '" data-confirmed="' . $user->confirmed . '" data-confirmation_date="' . $date . '">';
|
||||
return $user->confirmed ? $link . '<span class="badge badge-pill badge-success"><i class="fa fa-check"></i> ' . $date . '</span></a>' : $link . '<span class="badge badge-pill badge-danger"><i class="fa fa-times"></i></span></a>';
|
||||
$link = '<a href="#" data-toggle="modal" data-target="#modals-confirmed" data-id="'.$user->id.'" data-email="'.$user->email.'" data-confirmed="'.$user->confirmed.'" data-confirmation_date="'.$date.'">';
|
||||
|
||||
return $user->confirmed ? $link.'<span class="badge badge-pill badge-success"><i class="fa fa-check"></i> '.$date.'</span></a>' : $link.'<span class="badge badge-pill badge-danger"><i class="fa fa-times"></i></span></a>';
|
||||
})
|
||||
->addColumn('active', function (User $user) {
|
||||
if ($user->trashed()) {
|
||||
$date = Carbon::parse(now())->addDays(10)->format('d.m.Y H:i');
|
||||
$email = str_replace("delete-", "", $user->email);
|
||||
$link = '<a href="#" data-toggle="modal" data-target="#modals-restore" data-id="' . $user->id . '" data-email="' . $email . '" data-active="' . $user->active . '" data-payment_account="' . $date . '">';
|
||||
return $link . '<span class="badge badge-pill badge-info"><i class="fa fa-undo"></i> Account reaktivieren</span></a>';
|
||||
$email = str_replace('delete-', '', $user->email);
|
||||
$link = '<a href="#" data-toggle="modal" data-target="#modals-restore" data-id="'.$user->id.'" data-email="'.$email.'" data-active="'.$user->active.'" data-payment_account="'.$date.'">';
|
||||
|
||||
return $link.'<span class="badge badge-pill badge-info"><i class="fa fa-undo"></i> Account reaktivieren</span></a>';
|
||||
}
|
||||
$date = $user->getActiveDateFormat();
|
||||
$link = '<a href="#" data-toggle="modal" data-target="#modals-active" data-id="' . $user->id . '" data-email="' . $user->email . '" data-active="' . $user->active . '" data-active_date="' . $date . '">';
|
||||
return $user->active ? $link . '<span class="badge badge-pill badge-success"><i class="fa fa-check"></i> ' . $date . '</span></a>' : $link . '<span class="badge badge-pill badge-danger"><i class="fa fa-times"></i></span></a>';
|
||||
$link = '<a href="#" data-toggle="modal" data-target="#modals-active" data-id="'.$user->id.'" data-email="'.$user->email.'" data-active="'.$user->active.'" data-active_date="'.$date.'">';
|
||||
|
||||
return $user->active ? $link.'<span class="badge badge-pill badge-success"><i class="fa fa-check"></i> '.$date.'</span></a>' : $link.'<span class="badge badge-pill badge-danger"><i class="fa fa-times"></i></span></a>';
|
||||
})
|
||||
->addColumn('account', function (User $user) {
|
||||
$date = $user->getPaymentAccountDateFormat();
|
||||
$link = '<a href="#" data-toggle="modal" data-target="#modals-account" data-id="' . $user->id . '" data-email="' . $user->email . '" data-payment_account="' . $date . '">';
|
||||
$link = '<a href="#" data-toggle="modal" data-target="#modals-account" data-id="'.$user->id.'" data-email="'.$user->email.'" data-payment_account="'.$date.'">';
|
||||
if ($user->payment_account) {
|
||||
if ($user->isActiveAccount()) {
|
||||
return $link . '<span class="badge badge-pill badge-success"><i class="fa fa-check"></i> ' . $date . '</span></a>';
|
||||
return $link.'<span class="badge badge-pill badge-success"><i class="fa fa-check"></i> '.$date.'</span></a>';
|
||||
}
|
||||
return $link . '<span class="badge badge-pill badge-warning"><i class="fa fa-ban"></i> ' . $date . '</span></a>';
|
||||
|
||||
return $link.'<span class="badge badge-pill badge-warning"><i class="fa fa-ban"></i> '.$date.'</span></a>';
|
||||
}
|
||||
return $link . '<span class="badge badge-pill badge-danger"><i class="fa fa-times"></i></span></a>';
|
||||
|
||||
return $link.'<span class="badge badge-pill badge-danger"><i class="fa fa-times"></i></span></a>';
|
||||
})
|
||||
->addColumn('shop', function (User $user) {
|
||||
$date = $user->getPaymentShopDateFormat();
|
||||
$link = '<a href="#" data-toggle="modal" data-target="#modals-shop" data-id="' . $user->id . '" data-email="' . $user->email . '" data-payment_shop="' . $date . '">';
|
||||
$link = '<a href="#" data-toggle="modal" data-target="#modals-shop" data-id="'.$user->id.'" data-email="'.$user->email.'" data-payment_shop="'.$date.'">';
|
||||
if ($user->payment_shop) {
|
||||
if ($user->isActiveShop()) {
|
||||
return $link . '<span class="badge badge-pill badge-success"><i class="fa fa-check"></i> ' . $date . '</span></a>';
|
||||
return $link.'<span class="badge badge-pill badge-success"><i class="fa fa-check"></i> '.$date.'</span></a>';
|
||||
}
|
||||
return $link . '<span class="badge badge-pill badge-warning"><i class="fa fa-ban"></i> ' . $date . '</span></a>';
|
||||
|
||||
return $link.'<span class="badge badge-pill badge-warning"><i class="fa fa-ban"></i> '.$date.'</span></a>';
|
||||
}
|
||||
return $link . '<span class="badge badge-pill badge-danger"><i class="fa fa-times"></i></span></a>';
|
||||
|
||||
return $link.'<span class="badge badge-pill badge-danger"><i class="fa fa-times"></i></span></a>';
|
||||
})
|
||||
->addColumn('shop_domain', function (User $user) {
|
||||
return $user->shop ? '<a href="' . $user->shop->getSubdomain(false) . '" target="_blank">' . $user->shop->getSubdomain(false) . '</a>' : '';
|
||||
return $user->shop ? '<a href="'.$user->shop->getSubdomain(false).'" target="_blank">'.$user->shop->getSubdomain(false).'</a>' : '';
|
||||
})
|
||||
->addColumn('since', function (User $user) {
|
||||
if ($user->shop) {
|
||||
if ($user->shop->active) {
|
||||
return $user->shop->getActiveDateFormatSmall();
|
||||
}
|
||||
|
||||
return $user->shop->getActiveDateFormatSmall();
|
||||
}
|
||||
return "-";
|
||||
|
||||
return '-';
|
||||
})
|
||||
->addColumn('country', function (User $user) {
|
||||
return ($user->account && $user->account->country) ? $user->account->country->de : '';
|
||||
})
|
||||
->addColumn('my_payment_methods', function (User $user) {
|
||||
$payment_methods = json_encode($user->payment_methods);
|
||||
$link = '<a href="#" data-toggle="modal" data-target="#modals-payment_methods" data-id="' . $user->id . '" data-email="' . $user->email . '" data-payment_methods="' . htmlspecialchars($payment_methods) . '">';
|
||||
if (!$user->payment_methods) {
|
||||
return $link . '<span class="badge badge-pill badge-danger"><i class="fa fa-times"></i></span></a>';
|
||||
$link = '<a href="#" data-toggle="modal" data-target="#modals-payment_methods" data-id="'.$user->id.'" data-email="'.$user->email.'" data-payment_methods="'.htmlspecialchars($payment_methods).'">';
|
||||
if (! $user->payment_methods) {
|
||||
return $link.'<span class="badge badge-pill badge-danger"><i class="fa fa-times"></i></span></a>';
|
||||
}
|
||||
return $link . '<span class="badge badge-pill badge-success"><i class="fa fa-check"></i> ' . $user->getPaymentMethodsShort() . '</span></a>';
|
||||
|
||||
return $link.'<span class="badge badge-pill badge-success"><i class="fa fa-check"></i> '.$user->getPaymentMethodsShort().'</span></a>';
|
||||
})
|
||||
->addColumn('action_login', function (User $user) {
|
||||
return '<a href="' . route('admin_user_login_as', [$user->id]) . '" class="btn icon-btn btn-sm btn-warning" onclick="return confirm(\'' . __('Login as User?') . '\');"><span class="fa fa-sign-in-alt"></span></a>';
|
||||
return '<a href="'.route('admin_user_login_as', [$user->id]).'" class="btn icon-btn btn-sm btn-warning" onclick="return confirm(\''.__('Login as User?').'\');"><span class="fa fa-sign-in-alt"></span></a>';
|
||||
})
|
||||
->addColumn('action_delete', function (User $user) {
|
||||
return '<a class="btn icon-btn btn-sm btn-danger" href="#" data-toggle="modal" data-target="#modals-user-delete" data-id="' . $user->id . '" data-email="' . $user->email . '"><span class="fa fa-trash"></span></a>';
|
||||
return '<a class="btn icon-btn btn-sm btn-danger" href="#" data-toggle="modal" data-target="#modals-user-delete" data-id="'.$user->id.'" data-email="'.$user->email.'"><span class="fa fa-trash"></span></a>';
|
||||
})
|
||||
->addColumn('test_mode', function (User $user) {
|
||||
$link = '<a href="#" data-toggle="modal" data-target="#modals-test_mode" data-id="' . $user->id . '" data-email="' . $user->email . '" data-test_mode="' . $user->test_mode . '">';
|
||||
return $user->test_mode ? $link . '<span class="badge badge-pill badge-success"><i class="fa fa-check"></i></span></a>' : $link . '<span class="badge badge-pill badge-danger"><i class="fa fa-times"></i></span></a>';
|
||||
$link = '<a href="#" data-toggle="modal" data-target="#modals-test_mode" data-id="'.$user->id.'" data-email="'.$user->email.'" data-test_mode="'.$user->test_mode.'">';
|
||||
|
||||
return $user->test_mode ? $link.'<span class="badge badge-pill badge-success"><i class="fa fa-check"></i></span></a>' : $link.'<span class="badge badge-pill badge-danger"><i class="fa fa-times"></i></span></a>';
|
||||
})
|
||||
->orderColumn('id', 'id $1')
|
||||
->orderColumn('email', 'email $1')
|
||||
|
|
|
|||
|
|
@ -1,116 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Session;
|
||||
use \SoapClient;
|
||||
|
||||
class KasController extends Controller
|
||||
{
|
||||
|
||||
|
||||
// Logindaten
|
||||
private $kas_user = 'w017f6e4'; // KAS-Logon
|
||||
private $kas_pass = 'Medxiz-funteb-7dubdi'; // KAS-Passwort
|
||||
private $session_lifetime = 600; // Gültigkeit des Tokens in Sek. bis zur neuen Authentifizierung
|
||||
private $session_update_lifetime = 'Y'; // bei N läuft die Session nach <$session_lifetime> Sekunden ab, bei Y verlängert sich die Session mit jeder Benutzung
|
||||
private $CredentialToken = false;
|
||||
private $kas_flood_delay = 2;
|
||||
|
||||
/**
|
||||
* Create a new controller instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->login();
|
||||
}
|
||||
|
||||
|
||||
public function action($func, $para = array()){
|
||||
|
||||
$this->checkSession($func);
|
||||
try
|
||||
{
|
||||
$Params = array(); // Parameter für die API-Funktion
|
||||
$SoapRequest = new SoapClient('https://kasapi.kasserver.com/soap/wsdl/KasApi.wsdl', [
|
||||
'cache_wsdl' => WSDL_CACHE_NONE,
|
||||
'exceptions' => false
|
||||
]);
|
||||
$req = $SoapRequest->KasApi(json_encode(array(
|
||||
'KasUser' => $this->kas_user, // KAS-User
|
||||
'KasAuthType' => 'session', // Auth per Sessiontoken
|
||||
'KasAuthData' => $this->CredentialToken, // Auth-Token
|
||||
'KasRequestType' => $func, // API-Funktion
|
||||
'KasRequestParams' => $para // Parameter an die API-Funktion
|
||||
)));
|
||||
Session::put('flood_protection.'.$func, time() + $this->kas_flood_delay + 0.2);
|
||||
|
||||
if(is_array($req) && isset($req['Response']['ReturnString']) && $req['Response']['ReturnString'] == "TRUE"){
|
||||
return $req['Response']['ReturnInfo'];
|
||||
}
|
||||
return $req;
|
||||
}
|
||||
|
||||
// Fehler abfangen und ausgeben
|
||||
catch (\SoapFault $fault)
|
||||
{
|
||||
trigger_error(" Fehlernummer: {$fault->faultcode},
|
||||
Fehlermeldung: {$fault->faultstring},
|
||||
Verursacher: {$fault->faultactor},
|
||||
Details: {$fault->detail}", E_USER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private function login(){
|
||||
|
||||
$this->checkSession('auth');
|
||||
try
|
||||
{
|
||||
|
||||
$SoapLogon = new SoapClient('https://kasapi.kasserver.com/soap/wsdl/KasAuth.wsdl', [
|
||||
'cache_wsdl' => WSDL_CACHE_NONE,
|
||||
'exceptions' => false
|
||||
]);
|
||||
$this->CredentialToken = $SoapLogon->KasAuth(json_encode(array(
|
||||
'KasUser' => $this->kas_user,
|
||||
'KasAuthType' => 'plain',
|
||||
'KasPassword' => $this->kas_pass,
|
||||
'SessionLifeTime' => $this->session_lifetime,
|
||||
'SessionUpdateLifeTime' => $this->session_update_lifetime
|
||||
)));
|
||||
Session::put('flood_protection.auth', time() + $this->kas_flood_delay + 0.2);
|
||||
|
||||
}
|
||||
|
||||
// Fehler abfangen und ausgeben
|
||||
catch (\SoapFault $fault)
|
||||
{
|
||||
trigger_error("Fehlernummer: {$fault->faultcode},
|
||||
Fehlermeldung: {$fault->faultstring},
|
||||
Verursacher: {$fault->faultactor},
|
||||
Details: {$fault->detail}", E_USER_ERROR);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private function checkSession($func)
|
||||
{
|
||||
$name = 'flood_protection.'.$func;
|
||||
|
||||
if(Session::exists($name)){
|
||||
$time_to_wait = (float)Session::get($name) - time();
|
||||
Session::forget($name);
|
||||
}else {
|
||||
$time_to_wait = 0;
|
||||
}
|
||||
if ( $time_to_wait >= 0 ) {
|
||||
usleep( intval( $time_to_wait*1000000 ) );
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -11,7 +11,9 @@ use App\Models\ShoppingOrder;
|
|||
use App\Models\ShoppingOrderItem;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Services\CustomerPriority;
|
||||
use App\Services\Shop;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Yard;
|
||||
|
|
@ -28,7 +30,7 @@ class ShoppingUserController extends Controller
|
|||
/**
|
||||
* @param Request $request
|
||||
* wp_order_numbers[1234, 1234]
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function status(Request $request)
|
||||
{
|
||||
|
|
@ -74,7 +76,7 @@ class ShoppingUserController extends Controller
|
|||
/**
|
||||
* @param Request $request
|
||||
* wp_order_number [1234]
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function cancel(Request $request)
|
||||
{
|
||||
|
|
@ -126,7 +128,7 @@ class ShoppingUserController extends Controller
|
|||
/**
|
||||
* @param Request $request
|
||||
* wp_order_number [1234]
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function open(Request $request)
|
||||
{
|
||||
|
|
@ -178,7 +180,7 @@ class ShoppingUserController extends Controller
|
|||
/**
|
||||
* @param Request $request
|
||||
* wp_order_numbers [1234, 1234]
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function show(Request $request)
|
||||
{
|
||||
|
|
@ -229,7 +231,7 @@ class ShoppingUserController extends Controller
|
|||
}
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
|
|
@ -258,7 +260,7 @@ class ShoppingUserController extends Controller
|
|||
|
||||
// Kundenhoheit prüfen
|
||||
$priority = CustomerPriority::checkOne($shopping_user, true, false, true);
|
||||
\App\Services\Shop::newUserOrder($shopping_user->number);
|
||||
Shop::newUserOrder($shopping_user->number);
|
||||
// exists //like //update
|
||||
$user = $this->prepareForShow($shopping_user);
|
||||
|
||||
|
|
@ -277,7 +279,7 @@ class ShoppingUserController extends Controller
|
|||
}
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function update(Request $request)
|
||||
{
|
||||
|
|
@ -297,7 +299,7 @@ class ShoppingUserController extends Controller
|
|||
// Kundenhoheit prüfen
|
||||
$priority = CustomerPriority::checkChangeOne($shopping_user, $data, true);
|
||||
$updated = $shopping_user->fill($data)->save();
|
||||
\App\Services\Shop::newUserOrder($shopping_user->number);
|
||||
Shop::newUserOrder($shopping_user->number);
|
||||
|
||||
if ($updated) {
|
||||
$user = $this->prepareForShow($shopping_user);
|
||||
|
|
@ -610,6 +612,12 @@ class ShoppingUserController extends Controller
|
|||
|
||||
$ShippingCountry = ShippingCountry::whereCountryId($shopping_user->shipping_country_id)->first();
|
||||
if ($ShippingCountry) {
|
||||
Yard::instance('shopping')->setUserPriceInfos([
|
||||
'shipping_free' => $ShippingCountry->shipping?->free ?? false,
|
||||
'user_tax_free' => false,
|
||||
'user_reverse_charge' => false,
|
||||
'user_country_id' => $shopping_user->billing_country_id,
|
||||
]);
|
||||
Yard::instance('shopping')->setShippingCountryWithPrice($ShippingCountry->id);
|
||||
}
|
||||
$shopping_order = $this->makeShoppingOrder($shopping_user, $wp_invoice_path, $api_notice);
|
||||
|
|
|
|||
|
|
@ -2,14 +2,17 @@
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Exceptions\GdprDeletionException;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Repositories\CustomerRepository;
|
||||
use App\Services\CustomerPriority;
|
||||
use App\Services\GdprDeletionService;
|
||||
use App\Services\HTMLHelper;
|
||||
use App\Services\Shop;
|
||||
use Auth;
|
||||
use Request;
|
||||
use Validator;
|
||||
|
||||
|
||||
class CustomerController extends Controller
|
||||
{
|
||||
protected $customerRepository;
|
||||
|
|
@ -17,6 +20,7 @@ class CustomerController extends Controller
|
|||
public function __construct(CustomerRepository $customerRepository)
|
||||
{
|
||||
$this->middleware('admin');
|
||||
$this->middleware('superadmin')->only('gdprDelete');
|
||||
$this->customerRepository = $customerRepository;
|
||||
}
|
||||
|
||||
|
|
@ -25,16 +29,17 @@ class CustomerController extends Controller
|
|||
if (Request::get('reset') === 'filter') {
|
||||
set_user_attr('filter_member_id', null);
|
||||
set_user_attr('filter_customer_member', null);
|
||||
|
||||
return redirect(route('admin_customers'));
|
||||
}
|
||||
$filter_members = ShoppingUser::join('users', 'member_id', '=', 'users.id')->groupBy('member_id')->join('user_accounts', 'account_id', '=', 'user_accounts.id')->select('users.id', 'users.email', 'user_accounts.first_name', 'user_accounts.last_name')->get(); //->pluck('email', 'id')->unique()->toArray();
|
||||
$filter_members = ShoppingUser::join('users', 'member_id', '=', 'users.id')->groupBy('member_id')->join('user_accounts', 'account_id', '=', 'user_accounts.id')->select('users.id', 'users.email', 'user_accounts.first_name', 'user_accounts.last_name')->get(); // ->pluck('email', 'id')->unique()->toArray();
|
||||
$data = [
|
||||
'filter_members' => $filter_members,
|
||||
];
|
||||
|
||||
return view('admin.customer.index', $data);
|
||||
}
|
||||
|
||||
|
||||
public function detail($id)
|
||||
{
|
||||
$shopping_user = ShoppingUser::findOrFail($id);
|
||||
|
|
@ -43,6 +48,7 @@ class CustomerController extends Controller
|
|||
'isAdmin' => true,
|
||||
'isView' => 'customer',
|
||||
];
|
||||
|
||||
return view('admin.customer.detail', $data);
|
||||
}
|
||||
|
||||
|
|
@ -50,20 +56,44 @@ class CustomerController extends Controller
|
|||
{
|
||||
$shopping_user = ShoppingUser::findOrFail($id);
|
||||
$result = $this->customerRepository->deleteCustomer($shopping_user);
|
||||
if (!$result) {
|
||||
if (! $result) {
|
||||
return back()->with('alert-error', 'Kunde hat Bestellungen. Löschen nicht möglich.');
|
||||
} else {
|
||||
\Session()->flash('alert-success', 'Kunde wurde gelöscht');
|
||||
|
||||
return redirect(route('admin_customers'));
|
||||
}
|
||||
|
||||
return redirect(route('admin_customers'));
|
||||
}
|
||||
|
||||
public function gdprDelete($id)
|
||||
{
|
||||
$shopping_user = ShoppingUser::withTrashed()->findOrFail($id);
|
||||
|
||||
if (! Request::get('gdpr_delete_customer')) {
|
||||
\Session()->flash('alert-danger', __('msg.gdpr_confirm_checkbox_missing'));
|
||||
|
||||
return redirect(route('admin_customer_detail', [$shopping_user->id]));
|
||||
}
|
||||
|
||||
try {
|
||||
app(GdprDeletionService::class)->deleteShoppingUser($shopping_user, Auth::id());
|
||||
\Session()->flash('alert-success', __('msg.gdpr_delete_customer_success'));
|
||||
} catch (GdprDeletionException $e) {
|
||||
\Session()->flash('alert-danger', $e->getMessage());
|
||||
|
||||
return redirect(route('admin_customer_detail', [$shopping_user->id]));
|
||||
}
|
||||
|
||||
return redirect(route('admin_customers'));
|
||||
}
|
||||
|
||||
public function edit($id)
|
||||
{
|
||||
if ($id === "new") {
|
||||
$shopping_user = new ShoppingUser();
|
||||
$shopping_user->id = "new";
|
||||
if ($id === 'new') {
|
||||
$shopping_user = new ShoppingUser;
|
||||
$shopping_user->id = 'new';
|
||||
} else {
|
||||
$shopping_user = ShoppingUser::findOrFail($id);
|
||||
}
|
||||
|
|
@ -72,6 +102,7 @@ class CustomerController extends Controller
|
|||
'isAdmin' => true,
|
||||
'isView' => 'customer',
|
||||
];
|
||||
|
||||
return view('admin.customer.edit', $data);
|
||||
}
|
||||
|
||||
|
|
@ -80,23 +111,25 @@ class CustomerController extends Controller
|
|||
$data = Request::all();
|
||||
|
||||
if ($data['action'] === 'shopping-user-change-member') {
|
||||
if (!isset($data['change_member_key']) || $data['change_member_key'] !== config('mivita.edit_data_pass')) {
|
||||
if (! isset($data['change_member_key']) || $data['change_member_key'] !== config('mivita.edit_data_pass')) {
|
||||
$data = [
|
||||
'change_member_error' => "Das Passwort ist falsch.",
|
||||
'change_member_error' => 'Das Passwort ist falsch.',
|
||||
'shopping_user' => ShoppingUser::find($id),
|
||||
'isAdmin' => true,
|
||||
'isView' => 'customer',
|
||||
];
|
||||
|
||||
return view('admin.customer.detail', $data);
|
||||
}
|
||||
//change
|
||||
// change
|
||||
$shopping_user = ShoppingUser::findOrFail($data['id']);
|
||||
CustomerPriority::newMemberForCustomer($shopping_user, $data['change_member_id'], $data['customer_set_member_for']);
|
||||
\Session()->flash('alert-save', true);
|
||||
|
||||
return redirect(route('admin_customer_detail', [$shopping_user->id]));
|
||||
}
|
||||
if ($data['action'] === 'shopping-user-store') {
|
||||
$rules = array(
|
||||
$rules = [
|
||||
'billing_salutation' => 'required',
|
||||
'billing_firstname' => 'required',
|
||||
'billing_lastname' => 'required',
|
||||
|
|
@ -104,10 +137,10 @@ class CustomerController extends Controller
|
|||
'billing_address' => 'required',
|
||||
'billing_zipcode' => 'required',
|
||||
'billing_city' => 'required',
|
||||
'billing_country_id' => 'required'
|
||||
);
|
||||
'billing_country_id' => 'required',
|
||||
];
|
||||
|
||||
if (!Request::get('same_as_billing')) {
|
||||
if (! Request::get('same_as_billing')) {
|
||||
$rules = array_merge($rules, [
|
||||
'shipping_firstname' => 'required',
|
||||
'shipping_lastname' => 'required',
|
||||
|
|
@ -115,7 +148,7 @@ class CustomerController extends Controller
|
|||
'shipping_zipcode' => 'required',
|
||||
'shipping_city' => 'required',
|
||||
'shipping_salutation' => 'required',
|
||||
'shipping_country_id' => 'required'
|
||||
'shipping_country_id' => 'required',
|
||||
]);
|
||||
}
|
||||
$validator = Validator::make(Request::all(), $rules);
|
||||
|
|
@ -128,7 +161,7 @@ class CustomerController extends Controller
|
|||
$data['language'] = isset($data['language']) ? $data['language'] : \App::getLocale();
|
||||
$data['has_buyed'] = isset($data['has_buyed']) ? true : false;
|
||||
$data['subscribed'] = isset($data['subscribed']) ? true : false;
|
||||
//subscribed can only true when has_buyed ist active
|
||||
// subscribed can only true when has_buyed ist active
|
||||
$data['subscribed'] = $data['has_buyed'] ? $data['subscribed'] : false;
|
||||
|
||||
/* if($shopping_user->auth_user_id > 0){
|
||||
|
|
@ -140,21 +173,23 @@ class CustomerController extends Controller
|
|||
CustomerPriority::checkChangeOne($shopping_user, $data, true);
|
||||
$shopping_user->fill($data);
|
||||
$shopping_user->save();
|
||||
\App\Services\Shop::newUserOrder($shopping_user->number);
|
||||
Shop::newUserOrder($shopping_user->number);
|
||||
|
||||
\Session()->flash('alert-save', true);
|
||||
}
|
||||
|
||||
return redirect(route('admin_customer_detail', [$shopping_user->id]));
|
||||
}
|
||||
|
||||
public function getCustomers()
|
||||
{
|
||||
$query = ShoppingUser::select('shopping_users.*')->where('auth_user_id', '=', NULL);
|
||||
$query = ShoppingUser::select('shopping_users.*')->where('auth_user_id', '=', null);
|
||||
|
||||
set_user_attr('filter_member_id', Request::get('filter_member_id'));
|
||||
if (Request::get('filter_member_id') != "") {
|
||||
if (Request::get('filter_member_id') != '') {
|
||||
$query->where('member_id', '=', Request::get('filter_member_id'));
|
||||
}
|
||||
|
||||
/* set_user_attr('filter_customer_member', Request::get('filter_customer_member'));
|
||||
if(Request::get('filter_customer_member') != ""){
|
||||
if(Request::get('filter_customer_member') === 'customers'){
|
||||
|
|
@ -166,10 +201,10 @@ class CustomerController extends Controller
|
|||
}*/
|
||||
return \DataTables::eloquent($query)
|
||||
->addColumn('id', function (ShoppingUser $ShoppingUser) {
|
||||
return '<a href="' . route('admin_customer_detail', [$ShoppingUser->id]) . '" class="btn icon-btn btn-sm btn-primary"><span class="fa fa-edit"></span></a>';
|
||||
return '<a href="'.route('admin_customer_detail', [$ShoppingUser->id]).'" class="btn icon-btn btn-sm btn-primary"><span class="fa fa-edit"></span></a>';
|
||||
})
|
||||
->addColumn('billing_email', function (ShoppingUser $ShoppingUser) {
|
||||
return $ShoppingUser->faker_mail ? "-" : $ShoppingUser->billing_email;
|
||||
return $ShoppingUser->faker_mail ? '-' : $ShoppingUser->billing_email;
|
||||
})
|
||||
->addColumn('billing_salutation', function (ShoppingUser $ShoppingUser) {
|
||||
return HTMLHelper::getSalutationLang($ShoppingUser->billing_salutation);
|
||||
|
|
@ -178,19 +213,19 @@ class CustomerController extends Controller
|
|||
return $ShoppingUser->billing_country ? $ShoppingUser->billing_country->getLocated() : '';
|
||||
})
|
||||
->addColumn('isMember', function (ShoppingUser $ShoppingUser) {
|
||||
return get_active_badge($ShoppingUser->auth_user_id) . ($ShoppingUser->mode === 'dev' ? ' <span class="badge badge-warning">dev</span>' : '');
|
||||
return get_active_badge($ShoppingUser->auth_user_id).($ShoppingUser->mode === 'dev' ? ' <span class="badge badge-warning">dev</span>' : '');
|
||||
})
|
||||
->addColumn('member_id', function (ShoppingUser $ShoppingUser) {
|
||||
if ($ShoppingUser->is_like) {
|
||||
return '<button type="button" class="btn btn-xs btn-outline-info" data-toggle="modal" data-target="#modals-load-content"
|
||||
data-id="' . $ShoppingUser->id . '"
|
||||
data-id="'.$ShoppingUser->id.'"
|
||||
data-action="shopping-user-is-like-member"
|
||||
data-back="' . route('admin_customers') . '"
|
||||
data-back="'.route('admin_customers').'"
|
||||
data-modal="modal-xl"
|
||||
data-route="' . route('modal_load') . '"><span class="fa fa-edit"></span> Berater zuordnen</button>';
|
||||
data-route="'.route('modal_load').'"><span class="fa fa-edit"></span> Berater zuordnen</button>';
|
||||
}
|
||||
if ($ShoppingUser->member) {
|
||||
return '<a href="' . route('admin_lead_edit', [$ShoppingUser->member_id]) . '">' . $ShoppingUser->member->getFullName() . '</a>';
|
||||
return '<a href="'.route('admin_lead_edit', [$ShoppingUser->member_id]).'">'.$ShoppingUser->member->getFullName().'</a>';
|
||||
}
|
||||
|
||||
return '';
|
||||
|
|
@ -202,8 +237,8 @@ class CustomerController extends Controller
|
|||
return get_active_badge($ShoppingUser->subscribed);
|
||||
})
|
||||
->filterColumn('billing_email', function ($query, $keyword) {
|
||||
if ($keyword != "") {
|
||||
$query->where('billing_email', 'LIKE', '%' . $keyword . '%');
|
||||
if ($keyword != '') {
|
||||
$query->where('billing_email', 'LIKE', '%'.$keyword.'%');
|
||||
}
|
||||
})
|
||||
->orderColumn('id', 'id $1')
|
||||
|
|
|
|||
|
|
@ -2,7 +2,11 @@
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\DcFile;
|
||||
use App\Models\File;
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\UserCredit;
|
||||
use App\Models\UserInvoice;
|
||||
use App\Repositories\CreditRepository;
|
||||
use Auth;
|
||||
use Response;
|
||||
|
|
@ -78,7 +82,7 @@ class FileController extends Controller
|
|||
}
|
||||
}*/
|
||||
if ($from === 'invoice') {
|
||||
$shopping_order = \App\Models\ShoppingOrder::findOrFail($id);
|
||||
$shopping_order = ShoppingOrder::findOrFail($id);
|
||||
if ($shopping_order->user_invoice) {
|
||||
$this->isPermissionShoppingOrder($shopping_order);
|
||||
$user_invoice = $shopping_order->user_invoice;
|
||||
|
|
@ -95,7 +99,7 @@ class FileController extends Controller
|
|||
}
|
||||
|
||||
if ($from === 'delivery') {
|
||||
$shopping_order = \App\Models\ShoppingOrder::findOrFail($id);
|
||||
$shopping_order = ShoppingOrder::findOrFail($id);
|
||||
if ($shopping_order->user_invoice) {
|
||||
$this->isPermissionShoppingOrder($shopping_order);
|
||||
$user_invoice = $shopping_order->user_invoice;
|
||||
|
|
@ -121,12 +125,12 @@ class FileController extends Controller
|
|||
}
|
||||
|
||||
if ($from === 'cancellation') {
|
||||
$shopping_order = \App\Models\ShoppingOrder::findOrFail($id);
|
||||
$shopping_order = ShoppingOrder::findOrFail($id);
|
||||
$this->isPermissionShoppingOrder($shopping_order);
|
||||
|
||||
// Stornorechnung finden: cancellation=true UND cancellation_id=null
|
||||
// (Die Original-Rechnung hat auch cancellation=true, aber MIT cancellation_id)
|
||||
$cancellation_invoice = \App\Models\UserInvoice::where('shopping_order_id', $shopping_order->id)
|
||||
$cancellation_invoice = UserInvoice::where('shopping_order_id', $shopping_order->id)
|
||||
->where('cancellation', true)
|
||||
->whereNull('cancellation_id')
|
||||
->first();
|
||||
|
|
@ -147,7 +151,7 @@ class FileController extends Controller
|
|||
}
|
||||
|
||||
if ($from === 'credit') {
|
||||
$user_credit = \App\Models\UserCredit::findOrFail($id);
|
||||
$user_credit = UserCredit::findOrFail($id);
|
||||
$this->isPermissionUserCredit($user_credit);
|
||||
$disk = $user_credit->disk;
|
||||
// Lokalisierte Version wenn angegeben
|
||||
|
|
@ -161,7 +165,7 @@ class FileController extends Controller
|
|||
}
|
||||
|
||||
if ($from === 'credit_detail') {
|
||||
$user_credit = \App\Models\UserCredit::findOrFail($id);
|
||||
$user_credit = UserCredit::findOrFail($id);
|
||||
$this->isPermissionUserCredit($user_credit);
|
||||
|
||||
return $this->create_credit_detail($user_credit, $do);
|
||||
|
|
@ -175,14 +179,14 @@ class FileController extends Controller
|
|||
|
||||
if ($from === 'dc_file') {
|
||||
// $this->isPermissionAuth();
|
||||
$dc_file = \App\Models\DcFile::findOrFail($id);
|
||||
$dc_file = DcFile::findOrFail($id);
|
||||
$filename = $dc_file->filename;
|
||||
$disk = 'public';
|
||||
$path = $dc_file->getFile();
|
||||
}
|
||||
if ($from === 'dc_thumb') {
|
||||
// $this->isPermissionAuth();
|
||||
$dc_file = \App\Models\DcFile::findOrFail($id);
|
||||
$dc_file = DcFile::findOrFail($id);
|
||||
$filename = $dc_file->filename;
|
||||
$disk = 'public';
|
||||
$path = $dc_file->getThumb();
|
||||
|
|
@ -190,14 +194,14 @@ class FileController extends Controller
|
|||
|
||||
if ($from === 'dc_big') {
|
||||
// $this->isPermissionAuth();
|
||||
$dc_file = \App\Models\DcFile::findOrFail($id);
|
||||
$dc_file = DcFile::findOrFail($id);
|
||||
$filename = $dc_file->filename;
|
||||
$disk = 'public';
|
||||
$path = $dc_file->getBig();
|
||||
}
|
||||
|
||||
if ($from === 'user') {
|
||||
$file = \App\Models\File::findOrFail($id);
|
||||
$file = File::findOrFail($id);
|
||||
$filename = $file->filename;
|
||||
$disk = 'user';
|
||||
$path = $file->dir.$file->filename;
|
||||
|
|
@ -244,6 +248,7 @@ class FileController extends Controller
|
|||
|
||||
private function create_credit_detail(UserCredit $user_credit, $do)
|
||||
{
|
||||
abort_if($user_credit->user === null, 404, 'User zur Gutschrift nicht gefunden.');
|
||||
|
||||
$credit_repo = new CreditRepository($user_credit->user);
|
||||
|
||||
|
|
|
|||
|
|
@ -7,15 +7,15 @@ use App\Models\Incentive;
|
|||
use App\Models\IncentiveParticipant;
|
||||
use App\Models\ShoppingPayment;
|
||||
use App\Models\UserAbo;
|
||||
use App\Models\UserBusiness;
|
||||
use App\Services\Abo\PaylineForecastService;
|
||||
use App\Services\Abo\TeamAboOverviewService;
|
||||
use App\Services\AboHelper;
|
||||
use App\Services\Backoffice\BackofficeDashboardService;
|
||||
use App\Services\BusinessPlan\TreeCalcBot;
|
||||
use App\Services\BusinessPlan\LiveCommissionService;
|
||||
use App\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\View\View;
|
||||
use Request;
|
||||
use Util;
|
||||
|
||||
|
|
@ -38,11 +38,11 @@ class HomeController extends Controller
|
|||
return redirect('home');
|
||||
}
|
||||
|
||||
public function newsArchive(): \Illuminate\View\View
|
||||
public function newsArchive(): View
|
||||
{
|
||||
return view('dashboard.news_archive', [
|
||||
'currentNews' => \App\Models\DashboardNews::getActiveNews(),
|
||||
'archiveNews' => \App\Models\DashboardNews::getArchiveNews(),
|
||||
'currentNews' => DashboardNews::getActiveNews(),
|
||||
'archiveNews' => DashboardNews::getArchiveNews(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -111,10 +111,18 @@ class HomeController extends Controller
|
|||
private function buildMonthlyStats(User $user): array
|
||||
{
|
||||
$month = max(1, min(12, (int) Request::get('stats_month', now()->month)));
|
||||
$year = (int) Request::get('stats_year', now()->year);
|
||||
$year = max(2020, min((int) now()->year + 1, (int) Request::get('stats_year', now()->year)));
|
||||
|
||||
$salesVolume = $user->getUserSalesVolume($month, $year, 'first');
|
||||
[$paylinePoints, $paylineIsLive] = $this->teamPaylinePoints($user, $month, $year);
|
||||
$customerPoints = $salesVolume ? (float) $salesVolume->getPointsKPSum() : 0.0;
|
||||
|
||||
// Payline-Prognose (B10) nur für den laufenden Monat — Basis ist der heutige
|
||||
// Stand aus Kundenumsatz + Payline-Punkten.
|
||||
$isCurrentMonth = $month === (int) now()->month && $year === (int) now()->year;
|
||||
$paylineForecast = $isCurrentMonth
|
||||
? app(PaylineForecastService::class)->forecastDays($user, $customerPoints + (float) $paylinePoints)
|
||||
: null;
|
||||
|
||||
$firstlineIds = User::where('m_sponsor', $user->id)
|
||||
->whereColumn('id', '!=', 'm_sponsor')
|
||||
|
|
@ -130,9 +138,11 @@ class HomeController extends Controller
|
|||
[
|
||||
'month' => $month,
|
||||
'year' => $year,
|
||||
'customer_points' => $salesVolume ? (float) $salesVolume->getPointsKPSum() : 0.0,
|
||||
'customer_points' => $customerPoints,
|
||||
'team_payline_points' => $paylinePoints,
|
||||
'payline_is_live' => $paylineIsLive,
|
||||
'payline_forecast' => $paylineForecast,
|
||||
'commission' => app(LiveCommissionService::class)->commission($user, $month, $year),
|
||||
'direct_new_partners' => $dashboardService->newPartnersCount($firstlineIds, $month, $year),
|
||||
],
|
||||
$teamOverview->teamKpis($user, $month, $year),
|
||||
|
|
@ -141,45 +151,16 @@ class HomeController extends Controller
|
|||
}
|
||||
|
||||
/**
|
||||
* Team-Payline-Punkte: aus dem UserBusiness-Snapshot (abgeschlossene Monate) oder
|
||||
* – für den laufenden Monat – einmalig live berechnet und für Folge-Aufrufe gecacht.
|
||||
* Team-Payline-Punkte: aus dem gemeinsamen Live-Lauf ({@see LiveCommissionService}),
|
||||
* der auch die Provision und die Payline-Tiefe liefert — ein Baum-Lauf für alles.
|
||||
*
|
||||
* @return array{0: float, 1: bool} [Punkte, istLive]
|
||||
*/
|
||||
private function teamPaylinePoints(User $user, int $month, int $year): array
|
||||
{
|
||||
$userBusiness = UserBusiness::where('user_id', $user->id)
|
||||
->where('month', $month)
|
||||
->where('year', $year)
|
||||
->first();
|
||||
$snapshot = app(LiveCommissionService::class)->snapshot($user, $month, $year);
|
||||
|
||||
if ($userBusiness) {
|
||||
return [(float) ($userBusiness->payline_points ?? 0), false];
|
||||
}
|
||||
|
||||
$points = Cache::remember(
|
||||
"home_payline_{$user->id}_{$month}_{$year}",
|
||||
now()->addHours(6),
|
||||
fn (): float => $this->calculatePaylineLive($user, $month, $year)
|
||||
);
|
||||
|
||||
return [(float) $points, true];
|
||||
}
|
||||
|
||||
private function calculatePaylineLive(User $user, int $month, int $year): float
|
||||
{
|
||||
try {
|
||||
$treeCalcBot = new TreeCalcBot($month, $year, 'member');
|
||||
$treeCalcBot->initBusinesslUserDetail($user);
|
||||
|
||||
if ($treeCalcBot->business_user) {
|
||||
return (float) ($treeCalcBot->business_user->payline_points ?? 0);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
\Log::warning("Live-Berechnung Payline-Punkte fehlgeschlagen für User {$user->id}: ".$e->getMessage());
|
||||
}
|
||||
|
||||
return 0.0;
|
||||
return [$snapshot['payline_points'], $snapshot['is_live']];
|
||||
}
|
||||
|
||||
public function loadingModal()
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Country;
|
||||
use App\Models\Homeparty;
|
||||
use App\Models\HomepartyUser;
|
||||
use App\Models\Product;
|
||||
|
|
@ -11,10 +12,12 @@ use App\Models\UserAbo;
|
|||
use App\Models\UserCredit;
|
||||
use App\Models\UserLevel;
|
||||
use App\Models\UserSalesVolume;
|
||||
use App\Services\Abo\TeamAboOverviewService;
|
||||
use App\Services\AboHelper;
|
||||
use App\Services\BusinessPlan\TreeCalcBot;
|
||||
use App\Services\BusinessPlan\TreeCalcBotOptimized;
|
||||
use App\Services\DhlModalService;
|
||||
use App\Services\DhlProductResolver;
|
||||
use App\User;
|
||||
use Request;
|
||||
|
||||
|
|
@ -141,7 +144,7 @@ class ModalController extends Controller
|
|||
}
|
||||
|
||||
if ($data['action'] === 'team-abo-member-show') {
|
||||
$summary = app(\App\Services\Abo\TeamAboOverviewService::class)
|
||||
$summary = app(TeamAboOverviewService::class)
|
||||
->memberAboSummary(\Auth::user(), (int) $data['id']);
|
||||
if ($summary) {
|
||||
$ret = view('user.team.modal_member_abos', compact('summary', 'data'))->render();
|
||||
|
|
@ -174,7 +177,7 @@ class ModalController extends Controller
|
|||
$ret = view('admin.payment.modal_add_credit', compact('value', 'data'))->render();
|
||||
}
|
||||
if ($data['action'] === 'user-credit-status') {
|
||||
$UserCredit = UserCredit::find($data['id']); // current user form order
|
||||
$UserCredit = UserCredit::findOrFail($data['id']); // current user form order
|
||||
$ret = view('admin.payment.modal_credit_status', compact('UserCredit', 'data'))->render();
|
||||
}
|
||||
if ($data['action'] === 'abo_update_settings') {
|
||||
|
|
@ -279,12 +282,12 @@ class ModalController extends Controller
|
|||
'order' => null,
|
||||
'orderWeight' => 1.0,
|
||||
'shippingAddress' => null,
|
||||
'availableCountries' => \App\Models\Country::where('active', 1)->get(),
|
||||
'availableCountries' => Country::where('active', 1)->get(),
|
||||
'productCodes' => [
|
||||
'V01PAK' => 'DHL Paket (National)',
|
||||
'V53WPAK' => 'DHL Paket International',
|
||||
],
|
||||
'productSuggestions' => (new \App\Services\DhlProductResolver)->getProductSuggestionsByCountry(),
|
||||
'productSuggestions' => (new DhlProductResolver)->getProductSuggestionsByCountry(),
|
||||
'selectedProductCode' => 'V01PAK',
|
||||
'errors' => ['Fehler beim Laden der Daten: '.$e->getMessage()],
|
||||
'warnings' => [],
|
||||
|
|
|
|||
|
|
@ -57,8 +57,12 @@ class AboController extends Controller
|
|||
}
|
||||
|
||||
$shopping_user = ShoppingUser::findOrFail($user->shopping_user_id);
|
||||
// Gekündigte/beendete Abos (Status 4/5) zählen nicht mehr als "mein Abo" —
|
||||
// nach einer Kündigung muss das Portal wieder die Neuanlage anbieten (Ein-Abo-Regel, A2).
|
||||
$user_abo = UserAbo::where('email', $shopping_user->billing_email)
|
||||
->where('status', '>=', 1)
|
||||
->whereNotIn('status', AboHelper::ENDED_STATUSES)
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if (! $user_abo) {
|
||||
|
|
@ -110,6 +114,7 @@ class AboController extends Controller
|
|||
if ($data['action'] === 'abo_update_settings') {
|
||||
$user_abo = UserAbo::findOrFail($data['id']);
|
||||
$this->checkPortalPermission($user_abo);
|
||||
$data['view'] = 'portal';
|
||||
$aboRepository = new AboRepository;
|
||||
$aboRepository->setModel($user_abo);
|
||||
$aboRepository->update($data);
|
||||
|
|
@ -243,6 +248,7 @@ class AboController extends Controller
|
|||
'summary' => $summary,
|
||||
'add_only_mode' => $isAddOnlyMode,
|
||||
'change_locked' => $changeLocked,
|
||||
'portal_view' => true,
|
||||
])->render();
|
||||
$html_comp = view('user.order.comp_product', array_merge($data, [
|
||||
'cart_instance' => AboOrderCart::INSTANCE,
|
||||
|
|
@ -250,6 +256,7 @@ class AboController extends Controller
|
|||
]))->render();
|
||||
$html_summary = view('admin.abo._order_combined_summary', [
|
||||
'summary' => $summary,
|
||||
'portal_view' => true,
|
||||
])->render();
|
||||
|
||||
$amount = $user_abo->getFormattedAmount();
|
||||
|
|
@ -409,9 +416,11 @@ class AboController extends Controller
|
|||
'only_show_products' => false,
|
||||
'add_only_mode' => $isAddOnlyMode,
|
||||
'change_locked' => $changeLocked,
|
||||
'portal_view' => true,
|
||||
])->render(),
|
||||
'html_summary' => view('admin.abo._order_combined_summary', [
|
||||
'summary' => $summary,
|
||||
'portal_view' => true,
|
||||
])->render(),
|
||||
'html_comp' => view('user.order.comp_product', [
|
||||
'comp_products' => [],
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ use App\Services\SyS\CleanHTMLProductDescription;
|
|||
use App\Services\SyS\Correction;
|
||||
use App\Services\SyS\Cronjobs;
|
||||
use App\Services\SyS\Customers;
|
||||
use App\Services\SyS\DomainSSL;
|
||||
use App\Services\SyS\Import;
|
||||
use App\Services\SyS\ImportDbipCountry;
|
||||
use App\Services\SyS\PayoneCallbackTestbench;
|
||||
|
|
@ -57,9 +56,6 @@ class SysController extends Controller
|
|||
case 'cronjobs':
|
||||
return Cronjobs::show();
|
||||
break;
|
||||
case 'domainssl':
|
||||
return DomainSSL::show();
|
||||
break;
|
||||
case 'shopping_orders':
|
||||
return ShoppingOrders::show();
|
||||
break;
|
||||
|
|
@ -115,9 +111,6 @@ class SysController extends Controller
|
|||
case 'cronjobs':
|
||||
return Cronjobs::store();
|
||||
break;
|
||||
case 'domainssl':
|
||||
return DomainSSL::store();
|
||||
break;
|
||||
case 'shopping_orders':
|
||||
return ShoppingOrders::store();
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -158,7 +158,13 @@ class AboController extends Controller
|
|||
|
||||
if (isset($data['action'])) {
|
||||
if ($data['action'] === 'abo_update_settings') {
|
||||
// Body-id kann von der Route abweichen: Berechtigungen erneut auf dem
|
||||
// tatsächlich bearbeiteten Abo prüfen. Die Sicht kommt ausschließlich aus
|
||||
// der (bereits geprüften) Route, nie aus dem Formular — sonst ließen sich
|
||||
// per view=admin sämtliche Selfservice-Regeln umgehen.
|
||||
$user_abo = UserAbo::findOrFail($data['id']);
|
||||
$this->checkPermissions($view, $user_abo);
|
||||
$data['view'] = $view;
|
||||
$this->aboRepository->setModel($user_abo);
|
||||
$this->aboRepository->update($data);
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ 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;
|
||||
|
|
@ -47,6 +48,7 @@ class BackofficeStatisticsController extends Controller
|
|||
'filterYears' => HTMLHelper::getYearRange(2022),
|
||||
'statistics' => $statistics,
|
||||
'performance' => $performance,
|
||||
'commission' => app(LiveCommissionService::class)->commission($request->user(), $selectedMonth, $selectedYear),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -76,11 +78,12 @@ class BackofficeStatisticsController extends Controller
|
|||
[$selectedMonth, $selectedYear] = $this->selectedPeriod($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,
|
||||
'details' => $this->drilldownService->details($request->user(), $line, $metric, $selectedMonth, $selectedYear),
|
||||
'details' => $this->drilldownService->details($request->user(), $line, $metric, $selectedMonth, $selectedYear, $onlyNew),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -93,10 +96,12 @@ class BackofficeStatisticsController extends Controller
|
|||
[$selectedMonth, $selectedYear] = $this->selectedPeriod($request);
|
||||
$line = (int) $request->get('line', 1);
|
||||
$metric = (string) $request->get('metric', 'consultants');
|
||||
$details = $this->drilldownService->details($request->user(), $line, $metric, $selectedMonth, $selectedYear);
|
||||
$onlyNew = $request->boolean('only_new');
|
||||
$details = $this->drilldownService->details($request->user(), $line, $metric, $selectedMonth, $selectedYear, $onlyNew);
|
||||
$filename = sprintf(
|
||||
'backoffice-statistik-%s-linie-%s-%02d-%d.csv',
|
||||
'backoffice-statistik-%s%s-linie-%s-%02d-%d.csv',
|
||||
$metric,
|
||||
$details['only_new'] ? '-neu' : '',
|
||||
$line === 0 ? 'alle' : $line,
|
||||
$selectedMonth,
|
||||
$selectedYear
|
||||
|
|
@ -156,7 +161,7 @@ class BackofficeStatisticsController extends Controller
|
|||
private function selectedPeriod(Request $request): array
|
||||
{
|
||||
$selectedMonth = max(1, min(12, (int) $request->get('month', session(self::SESSION_MONTH_KEY, now()->month))));
|
||||
$selectedYear = (int) $request->get('year', session(self::SESSION_YEAR_KEY, now()->year));
|
||||
$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,
|
||||
|
|
@ -232,6 +237,10 @@ class BackofficeStatisticsController extends Controller
|
|||
return ['Name', 'E-Mail', 'Karriere-Level', 'Eigenpunkte', 'Shop-Punkte', 'Abo-Punkte', 'Sonstige Punkte', 'Kundenpunkte', 'Gesamtpunkte'];
|
||||
}
|
||||
|
||||
if ($metric === 'new_partners') {
|
||||
return ['Name', 'E-Mail', 'Karriere-Level', 'Sponsor', 'Kundenpunkte', 'Aktiv seit', 'Account gueltig bis'];
|
||||
}
|
||||
|
||||
return ['Name', 'E-Mail', 'Karriere-Level', 'Aktiv seit', 'Account gueltig bis', 'Account Status'];
|
||||
}
|
||||
|
||||
|
|
@ -280,6 +289,18 @@ class BackofficeStatisticsController extends Controller
|
|||
];
|
||||
}
|
||||
|
||||
if ($metric === 'new_partners') {
|
||||
return [
|
||||
$row['name'] ?? '',
|
||||
$row['email'] ?? '',
|
||||
$row['career_level'] ?? '',
|
||||
$row['sponsor'] ?? '',
|
||||
$row['customer_points'] ?? 0,
|
||||
$row['active_date'] ?? '',
|
||||
$row['payment_account'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
$row['name'] ?? '',
|
||||
$row['email'] ?? '',
|
||||
|
|
@ -311,6 +332,10 @@ class BackofficeStatisticsController extends Controller
|
|||
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') {
|
||||
return ['Summe', $summary['count'].' Eintraege', '', '', $summary['customer_points'], '', '', ''];
|
||||
}
|
||||
|
||||
return ['Summe', $summary['count'].' Eintraege', '', '', '', ''];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,14 @@ namespace App\Http\Controllers\User;
|
|||
|
||||
use App\Exports\UserTeamExport;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\UserAbo;
|
||||
use App\Models\UserBusiness;
|
||||
use App\Models\UserLevel;
|
||||
use App\Models\UserSalesVolume;
|
||||
use App\Services\Abo\PaylineForecastService;
|
||||
use App\Services\Abo\TeamAboOverviewService;
|
||||
use App\Services\AboHelper;
|
||||
use App\Services\AboOrderCart;
|
||||
use App\Services\BusinessPlan\ExportBot;
|
||||
use App\Services\BusinessPlan\TreeCalcBot;
|
||||
use App\Services\BusinessPlan\TreeCalcBotOptimized;
|
||||
|
|
@ -16,6 +21,7 @@ use App\Services\LevelReportService;
|
|||
use App\Services\NextLevelBadgeHelper;
|
||||
use App\Services\TranslationHelper;
|
||||
use App\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use Request;
|
||||
|
||||
|
|
@ -318,6 +324,9 @@ class TeamController extends Controller
|
|||
})
|
||||
->addColumn('is_qual_kp', function ($teamUser) {
|
||||
$user = User::find($teamUser->user_id);
|
||||
if ($user === null) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return TreeHelperOptimized::generateQualKPBadgeForUser($user, $this->month, $this->year);
|
||||
})
|
||||
|
|
@ -652,7 +661,7 @@ class TeamController extends Controller
|
|||
$currentLevel = $user->user_level;
|
||||
|
||||
// Lade alle aktiven User Level, sortiert nach Position
|
||||
$userLevels = \App\Models\UserLevel::where('active', true)
|
||||
$userLevels = UserLevel::where('active', true)
|
||||
->orderBy('pos', 'asc')
|
||||
->get();
|
||||
|
||||
|
|
@ -706,7 +715,7 @@ class TeamController extends Controller
|
|||
* Konsolidierte Team-Abo-Übersicht: Abos im Team nach Ebenen/Linien gruppiert,
|
||||
* inkl. Eigen-Abos, Kundenabos und neuer Abos im gewählten Monat.
|
||||
*/
|
||||
public function showAbos(\App\Services\Abo\TeamAboOverviewService $overviewService)
|
||||
public function showAbos(TeamAboOverviewService $overviewService, PaylineForecastService $paylineService)
|
||||
{
|
||||
$user = User::find(\Auth::user()->id);
|
||||
$now = now();
|
||||
|
|
@ -725,6 +734,7 @@ class TeamController extends Controller
|
|||
'growthChart' => $this->buildGrowthChart($growth),
|
||||
'growthYear' => $growthYear,
|
||||
'filterYears' => HTMLHelper::getYearRange(2022),
|
||||
'paylineAboTurnover' => $paylineService->aboTurnover($user, (int) $now->month, (int) $now->year),
|
||||
];
|
||||
|
||||
return view('user.team.abos', $data);
|
||||
|
|
@ -767,7 +777,7 @@ class TeamController extends Controller
|
|||
* (`user_abos_team_show`) eingeflossen. Diese Route bleibt für Bookmarks/Altlinks
|
||||
* bestehen und leitet dauerhaft dorthin weiter.
|
||||
*/
|
||||
public function showTeamCustomerAbos(): \Illuminate\Http\RedirectResponse
|
||||
public function showTeamCustomerAbos(): RedirectResponse
|
||||
{
|
||||
return redirect()->route('user_abos_team_show');
|
||||
}
|
||||
|
|
@ -778,7 +788,7 @@ class TeamController extends Controller
|
|||
public function detailAbo($id)
|
||||
{
|
||||
$user = User::find(\Auth::user()->id);
|
||||
$user_abo = \App\Models\UserAbo::findOrFail($id);
|
||||
$user_abo = UserAbo::findOrFail($id);
|
||||
|
||||
// Prüfe ob das Abo zu einem Team-Mitglied gehört (effizient via Sponsor-Kette)
|
||||
if (! AboHelper::isUserInTeam($user->id, $user_abo->user_id)) {
|
||||
|
|
@ -786,9 +796,9 @@ class TeamController extends Controller
|
|||
}
|
||||
|
||||
// Lade Abo-Details (ähnlich wie AboController)
|
||||
\App\Services\AboOrderCart::initYard($user_abo);
|
||||
$customer_detail = \App\Services\AboOrderCart::getCustomerDetail();
|
||||
\App\Services\AboOrderCart::makeOrderYard($user_abo);
|
||||
AboOrderCart::initYard($user_abo);
|
||||
$customer_detail = AboOrderCart::getCustomerDetail();
|
||||
AboOrderCart::makeOrderYard($user_abo);
|
||||
|
||||
$data = [
|
||||
'user_abo' => $user_abo,
|
||||
|
|
@ -1071,7 +1081,7 @@ class TeamController extends Controller
|
|||
{
|
||||
$levels = [0 => 'Alle Level'];
|
||||
|
||||
$userLevels = \App\Models\UserLevel::orderBy('pos')->get(['id', 'name']);
|
||||
$userLevels = UserLevel::orderBy('pos')->get(['id', 'name']);
|
||||
foreach ($userLevels as $level) {
|
||||
$levels[$level->id] = $level->name;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,21 @@
|
|||
<?php
|
||||
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Api\KasController;
|
||||
use App\Models\UserShop;
|
||||
use App\Models\UserShopOnSite;
|
||||
use App\Repositories\UserRepository;
|
||||
use App\Services\Slim;
|
||||
use Auth;
|
||||
use Cviebrock\EloquentSluggable\Services\SlugService;
|
||||
use Request;
|
||||
use Response;
|
||||
use Validator;
|
||||
|
||||
|
||||
class UserShopController extends Controller
|
||||
{
|
||||
protected $db;
|
||||
|
||||
protected $userRepo;
|
||||
|
||||
public function __construct(UserRepository $userRepo)
|
||||
|
|
@ -28,9 +27,9 @@ class UserShopController extends Controller
|
|||
public function index()
|
||||
{
|
||||
$user = Auth::user();
|
||||
if ($user->shop && !$user->shop->set_defaults) {
|
||||
if ($user->shop && ! $user->shop->set_defaults) {
|
||||
if ($user->account) {
|
||||
$user->shop->title = $user->account->first_name . " " . $user->account->last_name;
|
||||
$user->shop->title = $user->account->first_name.' '.$user->account->last_name;
|
||||
}
|
||||
if ($user->account) {
|
||||
$user->shop->contact = $this->generate_contact($user);
|
||||
|
|
@ -45,6 +44,7 @@ class UserShopController extends Controller
|
|||
$data = [
|
||||
'user' => $user,
|
||||
];
|
||||
|
||||
return view('user.shop', $data);
|
||||
}
|
||||
|
||||
|
|
@ -56,6 +56,7 @@ class UserShopController extends Controller
|
|||
'localizations' => array_keys(config('localization.supportedLocales')),
|
||||
|
||||
];
|
||||
|
||||
return view('user.shop.translate', $data);
|
||||
}
|
||||
|
||||
|
|
@ -64,13 +65,14 @@ class UserShopController extends Controller
|
|||
$user = Auth::user();
|
||||
$data = Request::all();
|
||||
|
||||
if (!$user->shop) {
|
||||
if (! $user->shop) {
|
||||
abort(404);
|
||||
}
|
||||
foreach ($data['trans'] as $lang => $val) {
|
||||
$this->storeTranslations($user->shop, $lang, $val);
|
||||
}
|
||||
\Session()->flash('alert-save', true);
|
||||
|
||||
return redirect(route('user_shop_translate'));
|
||||
}
|
||||
|
||||
|
|
@ -79,7 +81,7 @@ class UserShopController extends Controller
|
|||
$user = Auth::user();
|
||||
$data = Request::all();
|
||||
|
||||
if (!$user->shop) {
|
||||
if (! $user->shop) {
|
||||
abort(404);
|
||||
}
|
||||
$user->shop->title = $data['title'];
|
||||
|
|
@ -100,34 +102,35 @@ class UserShopController extends Controller
|
|||
$user_shop->accessibility = trim(preg_replace('/\s*\n+/', "\n", $data['accessibility']));
|
||||
$user_shop->about = trim(preg_replace('/\s+/', ' ', $data['about']));
|
||||
$user_shop->save();
|
||||
|
||||
return;
|
||||
}
|
||||
$trans = $user_shop->trans;
|
||||
$trans[$lang]['contact'] = trim(preg_replace('/\s*\n+/', "\n", $data['contact']));
|
||||
$trans[$lang]['accessibility'] = trim(preg_replace('/\s*\n+/', "\n", $data['accessibility']));
|
||||
$trans[$lang]['about'] = trim(preg_replace('/\s+/', ' ', $data['about']));
|
||||
$trans[$lang]['about'] = trim(preg_replace('/\s+/', ' ', $data['about']));
|
||||
$user_shop->trans = $trans;
|
||||
$user_shop->save();
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
private function generate_contact($user)
|
||||
{
|
||||
$ret = "";
|
||||
$ret = '';
|
||||
$sep = "\n";
|
||||
|
||||
$ret = $user->account->street != "" ? $user->account->street : __('shop.your_street_number');
|
||||
$ret .= " • ";
|
||||
$ret .= $user->account->postal_code != "" ? $user->account->postal_code . " " : __('shop.your_zip_code');
|
||||
$ret .= $user->account->city != "" ? $user->account->city : __('shop.your_city');
|
||||
$ret = $user->account->street != '' ? $user->account->street : __('shop.your_street_number');
|
||||
$ret .= ' • ';
|
||||
$ret .= $user->account->postal_code != '' ? $user->account->postal_code.' ' : __('shop.your_zip_code');
|
||||
$ret .= $user->account->city != '' ? $user->account->city : __('shop.your_city');
|
||||
$ret .= $sep;
|
||||
|
||||
$pre = $user->account->pre_phone_id != "" ? $user->account->pre_phone->phone . " " : "";
|
||||
$ret .= __('shop.phone') . ": " . ($user->account->phone != "" ? $pre . $user->account->phone : __('shop.your_phone_number'));
|
||||
$pre = $user->account->pre_phone_id != '' ? $user->account->pre_phone->phone.' ' : '';
|
||||
$ret .= __('shop.phone').': '.($user->account->phone != '' ? $pre.$user->account->phone : __('shop.your_phone_number'));
|
||||
$ret .= $sep;
|
||||
|
||||
$pre = $user->account->pre_mobil_id != "" ? $user->account->pre_mobil->phone . " " : "";
|
||||
$ret .= __('shop.mobil') . ": " . ($user->account->mobil != "" ? $pre . $user->account->mobil : __('shop.your_mobile_number'));
|
||||
$pre = $user->account->pre_mobil_id != '' ? $user->account->pre_mobil->phone.' ' : '';
|
||||
$ret .= __('shop.mobil').': '.($user->account->mobil != '' ? $pre.$user->account->mobil : __('shop.your_mobile_number'));
|
||||
$ret .= $sep;
|
||||
|
||||
$ret .= $user->email;
|
||||
|
|
@ -142,31 +145,32 @@ class UserShopController extends Controller
|
|||
|
||||
$user = Auth::user();
|
||||
|
||||
if (!$user->shop) {
|
||||
if (! $user->shop) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
try {
|
||||
$image = \App\Services\Slim::getImages('images')[0];
|
||||
$image = Slim::getImages('images')[0];
|
||||
|
||||
if (isset($image['output']['data'])) {
|
||||
// Base64 of the image
|
||||
$data = $image['output']['data'];
|
||||
$file_ex = array('image/jpeg' => 'jpg', 'image/png' => 'png');
|
||||
$file_ex = ['image/jpeg' => 'jpg', 'image/png' => 'png'];
|
||||
|
||||
if (!isset($file_ex[$image['output']['type']])) {
|
||||
if (! isset($file_ex[$image['output']['type']])) {
|
||||
\Session()->flash('alert-danger', 'File is not jpg or png!');
|
||||
|
||||
return redirect(route('user_shop'));
|
||||
}
|
||||
|
||||
$ext = $file_ex[$image['output']['type']];
|
||||
// Original file name
|
||||
$name = $image['output']['name'];
|
||||
$name = \App\Services\Slim::sanitizeFileName($name);
|
||||
$name = uniqid() . '_' . $name;
|
||||
$name = Slim::sanitizeFileName($name);
|
||||
$name = uniqid().'_'.$name;
|
||||
|
||||
$data = \Storage::disk('public')->put(
|
||||
'images/shop/' . $name,
|
||||
'images/shop/'.$name,
|
||||
$data
|
||||
);
|
||||
|
||||
|
|
@ -177,15 +181,16 @@ class UserShopController extends Controller
|
|||
$user->shop->size = $image['input']['size'];
|
||||
$user->shop->save();
|
||||
|
||||
|
||||
|
||||
\Session()->flash('alert-success', __('msg.file_uploaded'));
|
||||
|
||||
return redirect(route('user_shop'));
|
||||
}
|
||||
\Session()->flash('alert-danger', __('msg.file_empty'));
|
||||
|
||||
return redirect(route('user_shop'));
|
||||
} catch (\Exception $e) {
|
||||
\Session()->flash('alert-danger', "Error: " . $e);
|
||||
\Session()->flash('alert-danger', 'Error: '.$e);
|
||||
|
||||
return redirect(route('user_shop'));
|
||||
}
|
||||
}
|
||||
|
|
@ -195,12 +200,12 @@ class UserShopController extends Controller
|
|||
|
||||
$user = Auth::user();
|
||||
|
||||
if (!$user->shop) {
|
||||
if (! $user->shop) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ($user->shop->filename) {
|
||||
$file = 'images/shop/' . $user->shop->filename;
|
||||
$file = 'images/shop/'.$user->shop->filename;
|
||||
\Storage::disk('public')->delete($file);
|
||||
|
||||
$user->shop->filename = null;
|
||||
|
|
@ -211,9 +216,11 @@ class UserShopController extends Controller
|
|||
$user->shop->save();
|
||||
|
||||
\Session()->flash('alert-success', __('msg.file_deleted'));
|
||||
|
||||
return redirect(route('user_shop'));
|
||||
}
|
||||
\Session()->flash('alert-danger', __('msg.file_not_found'));
|
||||
|
||||
return redirect(route('user_shop'));
|
||||
}
|
||||
|
||||
|
|
@ -223,32 +230,33 @@ class UserShopController extends Controller
|
|||
$user = Auth::user();
|
||||
$user_shop_id = Request::get('user_shop_id');
|
||||
|
||||
if (!$user->shop || $user->shop->id != $user_shop_id) {
|
||||
if (! $user->shop || $user->shop->id != $user_shop_id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
try {
|
||||
$image = \App\Services\Slim::getImages('images')[0];
|
||||
$image = Slim::getImages('images')[0];
|
||||
|
||||
if (isset($image['output']['data'])) {
|
||||
|
||||
// Base64 of the image
|
||||
$data = $image['output']['data'];
|
||||
$file_ex = array('image/jpeg' => 'jpg', 'image/png' => 'png');
|
||||
$file_ex = ['image/jpeg' => 'jpg', 'image/png' => 'png'];
|
||||
|
||||
if (!isset($file_ex[$image['output']['type']])) {
|
||||
if (! isset($file_ex[$image['output']['type']])) {
|
||||
\Session()->flash('alert-danger', 'File is not jpg or png!');
|
||||
|
||||
return redirect(route('user_shop'));
|
||||
}
|
||||
|
||||
$ext = $file_ex[$image['output']['type']];
|
||||
// Original file name
|
||||
$name = $image['output']['name'];
|
||||
$name = \App\Services\Slim::sanitizeFileName($name);
|
||||
$name = uniqid() . '_' . $name;
|
||||
$name = Slim::sanitizeFileName($name);
|
||||
$name = uniqid().'_'.$name;
|
||||
|
||||
$data = \Storage::disk('public')->put(
|
||||
'images/user_shop/' . $user->shop->id . '/' . $name,
|
||||
'images/user_shop/'.$user->shop->id.'/'.$name,
|
||||
$data
|
||||
);
|
||||
|
||||
|
|
@ -258,16 +266,19 @@ class UserShopController extends Controller
|
|||
'original_name' => $image['output']['name'],
|
||||
'ext' => $ext,
|
||||
'mine' => $image['output']['type'],
|
||||
'size' => $image['input']['size']
|
||||
'size' => $image['input']['size'],
|
||||
]);
|
||||
|
||||
\Session()->flash('alert-success', __('msg.file_uploaded'));
|
||||
|
||||
return redirect(route('user_shop'));
|
||||
}
|
||||
\Session()->flash('alert-danger', __('msg.file_empty'));
|
||||
|
||||
return redirect(route('user_shop'));
|
||||
} catch (\Exception $e) {
|
||||
\Session()->flash('alert-danger', "Error: " . $e);
|
||||
\Session()->flash('alert-danger', 'Error: '.$e);
|
||||
|
||||
return redirect(route('user_shop'));
|
||||
}
|
||||
}
|
||||
|
|
@ -276,74 +287,78 @@ class UserShopController extends Controller
|
|||
{
|
||||
|
||||
$user = Auth::user();
|
||||
if (!$user->shop || $user->shop->id != $user_shop_id) {
|
||||
if (! $user->shop || $user->shop->id != $user_shop_id) {
|
||||
abort(404);
|
||||
}
|
||||
$image = UserShopOnSite::findOrFail($image_id);
|
||||
|
||||
if ($image->user_shop_id == $user_shop_id) {
|
||||
$file = 'images/user_shop/' . $user_shop_id . '/' . $image->filename;
|
||||
$file = 'images/user_shop/'.$user_shop_id.'/'.$image->filename;
|
||||
\Storage::disk('public')->delete($file);
|
||||
|
||||
$image->delete();
|
||||
|
||||
\Session()->flash('alert-success', __('msg.file_deleted'));
|
||||
|
||||
return redirect(route('user_shop'));
|
||||
}
|
||||
\Session()->flash('alert-danger', __('msg.file_not_found'));
|
||||
|
||||
return redirect(route('user_shop'));
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
public function userShopRegisterForm()
|
||||
{
|
||||
|
||||
if (Request::get('shop_submit') == 'check') {
|
||||
$rules = array(
|
||||
$rules = [
|
||||
'user_shop_name' => ' required|alpha_dash|unique:user_shops,name|min:4|max:20|full_word_check',
|
||||
);
|
||||
];
|
||||
Validator::extend('full_word_check', function ($attribute, $value, $parameters, $validator) {
|
||||
if (in_array($value, config('profanity.full_word_check'))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
$validator = Validator::make(Request::all(), $rules);
|
||||
|
||||
if ($validator->fails()) {
|
||||
\Session()->flash('shop-name-error', 'error');
|
||||
|
||||
return redirect()->back()->withErrors($validator)->withInput(Request::all());
|
||||
}
|
||||
\Session()->flash('shop-name-error', 'check');
|
||||
if (Request::get('user_shop_id')) {
|
||||
return back()->withInput(Request::all());
|
||||
}
|
||||
|
||||
return redirect(route('user_shop'))->withInput(Request::all());
|
||||
}
|
||||
|
||||
if (Request::get('shop_submit') == 'action') {
|
||||
|
||||
$rules = array(
|
||||
$rules = [
|
||||
'user_shop_name' => ' required|alpha_dash|unique:user_shops,name|min:4|max:20|full_word_check',
|
||||
'user_shop_active' => 'accepted',
|
||||
|
||||
);
|
||||
];
|
||||
Validator::extend('full_word_check', function ($attribute, $value, $parameters, $validator) {
|
||||
if (in_array($value, config('profanity.full_word_check'))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
$validator = Validator::make(Request::all(), $rules);
|
||||
if ($validator->fails()) {
|
||||
\Session()->flash('shop-name-error', 'error');
|
||||
|
||||
return redirect()->back()->withErrors($validator)->withInput(Request::all());
|
||||
}
|
||||
\Session()->flash('shop-name-error', 'check');
|
||||
|
||||
//all is right - save
|
||||
// all is right - save
|
||||
$user = Auth::user();
|
||||
$data = Request::all();
|
||||
$slug = SlugService::createSlug(UserShop::class, 'slug', $data['user_shop_name']);
|
||||
|
|
@ -367,93 +382,51 @@ class UserShopController extends Controller
|
|||
);
|
||||
}
|
||||
\Session()->flash('alert-save', true);
|
||||
|
||||
return redirect(route('user_shop'));
|
||||
/*$ret = $this->userShopRegisterSubDomain($user_shop->slug);
|
||||
if($ret['success'] === true){
|
||||
\Session()->flash('alert-save', true);
|
||||
}else{
|
||||
$user_shop->forceDelete();
|
||||
\Session()->flash('alert-error', $ret['error']);
|
||||
}
|
||||
return redirect(route('user_shop'));*/
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function userShopRegisterSubDomain($slug)
|
||||
{
|
||||
|
||||
$kas = new KasController();
|
||||
$domain = 'mivita.care';
|
||||
|
||||
|
||||
//check if exisist
|
||||
$subdomains = $kas->action('get_subdomains');
|
||||
foreach ($subdomains as $subdomain) {
|
||||
if (!isset($subdomain['subdomain_name'])) {
|
||||
continue;
|
||||
}
|
||||
$sub = str_replace("." . $domain, '', $subdomain['subdomain_name']);
|
||||
if ($sub == $slug) {
|
||||
return ['success' => false, 'error' => __('shop.error_subdomain_exists')];
|
||||
}
|
||||
}
|
||||
//add
|
||||
$full_subdomain_name = $slug . "." . $domain;
|
||||
$pra = array(
|
||||
'subdomain_name' => $slug,
|
||||
'domain_name' => $domain,
|
||||
'subdomain_path' => '/mein.mivita.care/public/',
|
||||
'php_version' => config('app.php_version'),
|
||||
//'ssl_proxy' => 'Y',
|
||||
//'redirect_status' => 0
|
||||
);
|
||||
$add_subdomain = $kas->action('add_subdomain', $pra);
|
||||
if ($add_subdomain == $full_subdomain_name) {
|
||||
return ['success' => true];
|
||||
}
|
||||
return ['success' => false, 'error' => $add_subdomain];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string to ajax
|
||||
*/
|
||||
public function checkUserShopName()
|
||||
{
|
||||
|
||||
$rules = array(
|
||||
$rules = [
|
||||
'user_shop_name' => ' required|alpha_dash|unique:user_shops,name|min:4|max:20|full_word_check',
|
||||
);
|
||||
];
|
||||
Validator::extend('full_word_check', function ($attribute, $value, $parameters, $validator) {
|
||||
if (in_array($value, config('profanity.full_word_check'))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
$validator = Validator::make(Request::all(), $rules);
|
||||
|
||||
if ($validator->fails()) {
|
||||
//$messages = $validator->messages();
|
||||
return Response::json(array(
|
||||
// $messages = $validator->messages();
|
||||
return Response::json([
|
||||
'success' => false,
|
||||
'errors' => $validator->getMessageBag()->toArray()
|
||||
'errors' => $validator->getMessageBag()->toArray(),
|
||||
|
||||
));
|
||||
]);
|
||||
}
|
||||
$slug = SlugService::createSlug(UserShop::class, 'slug', Request::get('user_shop_name'));
|
||||
|
||||
return Response::json(array(
|
||||
return Response::json([
|
||||
'success' => true,
|
||||
'preview_user_shop_name' => "https://" . $slug . "." . config('app.domain') . config('app.tld_care'),
|
||||
));
|
||||
'preview_user_shop_name' => 'https://'.$slug.'.'.config('app.domain').config('app.tld_care'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function editName()
|
||||
{
|
||||
$user = Auth::user();
|
||||
$user_shop = $user->shop;
|
||||
if (!$user_shop) {
|
||||
if (! $user_shop) {
|
||||
abort(404);
|
||||
}
|
||||
$user_shop_domain = $user_shop->getSubdomain(false);
|
||||
|
|
@ -462,6 +435,7 @@ class UserShopController extends Controller
|
|||
'user_shop_id' => $user_shop->id,
|
||||
'user_shop_domain' => $user_shop_domain,
|
||||
];
|
||||
|
||||
return view('user.shop_edit_name', $data);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,11 +9,43 @@ use App\Models\Product;
|
|||
use App\Models\ProductCategory;
|
||||
use App\Services\LocaleGuard;
|
||||
use App\Services\Shop;
|
||||
use App\Services\UserService;
|
||||
use App\Services\Util;
|
||||
use Request;
|
||||
|
||||
class SiteController extends Controller
|
||||
{
|
||||
/**
|
||||
* Templates, die eigene Controller mit eigenen View-Daten haben und daher
|
||||
* nicht über die Catch-all-Route ({site}/{subsite?}) gerendert werden dürfen.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const PROTECTED_TEMPLATES = [
|
||||
'checkout',
|
||||
'checkout-error',
|
||||
'checkout-final',
|
||||
'checkout-is-final',
|
||||
'card',
|
||||
'kontakt',
|
||||
'contact-final',
|
||||
'registrierung',
|
||||
'registrierung_finish',
|
||||
'widerruf',
|
||||
'widerruf-final',
|
||||
'produkte-show',
|
||||
'produkte-item',
|
||||
'versandkosten',
|
||||
'zahlungsarten',
|
||||
];
|
||||
|
||||
private function isRenderableTemplate(string $template): bool
|
||||
{
|
||||
return ! str_starts_with($template, '_')
|
||||
&& ! in_array($template, self::PROTECTED_TEMPLATES, true)
|
||||
&& view()->exists('web.templates.'.$template);
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$this->setIPInfo();
|
||||
|
|
@ -83,7 +115,7 @@ class SiteController extends Controller
|
|||
// ist default
|
||||
|
||||
// sprache
|
||||
if (array_key_exists($country, \App\Services\UserService::getTransChange())) {
|
||||
if (array_key_exists($country, UserService::getTransChange())) {
|
||||
\Session::put('user_init_country', $country);
|
||||
\Session::put('locale', $country);
|
||||
\App::setLocale($country);
|
||||
|
|
@ -177,13 +209,13 @@ class SiteController extends Controller
|
|||
'yard_instance' => 'webshop',
|
||||
];
|
||||
if ($subsite) {
|
||||
if (! view()->exists('web.templates.'.$subsite)) {
|
||||
if (! $this->isRenderableTemplate($subsite)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return view('web.templates.'.$subsite, $data);
|
||||
}
|
||||
if (! view()->exists('web.templates.'.$site)) {
|
||||
if (! $this->isRenderableTemplate($site)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,9 +2,14 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
use Acme\Dhl\Models\DhlShipment;
|
||||
use App\Services\HTMLHelper;
|
||||
use App\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* App\Models\ShoppingOrder
|
||||
|
|
@ -23,16 +28,16 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
|||
* @property int|null $weight
|
||||
* @property int|null $paid
|
||||
* @property string|null $txaction
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property-read \App\User|null $auth_user
|
||||
* @property-read \App\Models\Country $country
|
||||
* @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\ShoppingOrderItem[] $shopping_order_items
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
* @property-read User|null $auth_user
|
||||
* @property-read Country $country
|
||||
* @property-read Collection|ShoppingOrderItem[] $shopping_order_items
|
||||
* @property-read int|null $shopping_order_items_count
|
||||
* @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\ShoppingPayment[] $shopping_payments
|
||||
* @property-read Collection|ShoppingPayment[] $shopping_payments
|
||||
* @property-read int|null $shopping_payments_count
|
||||
* @property-read \App\Models\ShoppingUser $shopping_user
|
||||
* @property-read \App\Models\UserShop $user_shop
|
||||
* @property-read ShoppingUser $shopping_user
|
||||
* @property-read UserShop $user_shop
|
||||
*
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingOrder newModelQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingOrder newQuery()
|
||||
|
|
@ -60,13 +65,13 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
|||
*
|
||||
* @property int|null $member_id
|
||||
* @property string|null $mode
|
||||
* @property-read \App\User|null $member
|
||||
* @property-read \App\Models\UserHistory|null $user_history
|
||||
* @property-read User|null $member
|
||||
* @property-read UserHistory|null $user_history
|
||||
*
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingOrder whereMemberId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingOrder whereMode($value)
|
||||
*
|
||||
* @property \Illuminate\Support\Carbon|null $deleted_at
|
||||
* @property Carbon|null $deleted_at
|
||||
* @property string|null $user_deleted_at
|
||||
*
|
||||
* @method static \Illuminate\Database\Query\Builder|\App\Models\ShoppingOrder onlyTrashed()
|
||||
|
|
@ -75,7 +80,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
|||
* @method static \Illuminate\Database\Query\Builder|\App\Models\ShoppingOrder withTrashed()
|
||||
* @method static \Illuminate\Database\Query\Builder|\App\Models\ShoppingOrder withoutTrashed()
|
||||
*
|
||||
* @property-read \App\Models\ShippingCountry $shipping_country
|
||||
* @property-read ShippingCountry $shipping_country
|
||||
* @property float|null $shipping_net
|
||||
* @property float|null $subtotal_ws
|
||||
* @property int|null $points
|
||||
|
|
@ -93,21 +98,21 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
|||
* @property int|null $homeparty_id
|
||||
* @property array|null $api_notice
|
||||
* @property int|null $api_status
|
||||
* @property-read \App\Models\Homeparty|null $homeparty
|
||||
* @property-read Homeparty|null $homeparty
|
||||
*
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ShoppingOrder whereHomepartyId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ShoppingOrder whereWpNotice($value)
|
||||
*
|
||||
* @property array|null $tax_split
|
||||
* @property-read \App\Models\UserInvoice|null $user_invoice
|
||||
* @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\UserSalesVolume[] $user_sales_volume
|
||||
* @property-read UserInvoice|null $user_invoice
|
||||
* @property-read Collection|UserSalesVolume[] $user_sales_volume
|
||||
* @property-read int|null $user_sales_volume_count
|
||||
*
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ShoppingOrder whereTaxSplit($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ShoppingOrder whereApiNotice($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ShoppingOrder whereApiStatus($value)
|
||||
*
|
||||
* @property-read \App\Models\ShoppingCollectOrder|null $shopping_collect_order
|
||||
* @property-read ShoppingCollectOrder|null $shopping_collect_order
|
||||
* @property array|null $net_split
|
||||
*
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ShoppingOrder whereNetSplit($value)
|
||||
|
|
@ -122,11 +127,11 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
|||
* @method static \Illuminate\Database\Eloquent\Builder|ShoppingOrder whereAboInterval($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ShoppingOrder whereIsAbo($value)
|
||||
*
|
||||
* @property-read \Illuminate\Database\Eloquent\Collection<int, \Acme\Dhl\Models\DhlShipment> $dhlOutboundShipments
|
||||
* @property-read Collection<int, DhlShipment> $dhlOutboundShipments
|
||||
* @property-read int|null $dhl_outbound_shipments_count
|
||||
* @property-read \Illuminate\Database\Eloquent\Collection<int, \Acme\Dhl\Models\DhlShipment> $dhlReturnShipments
|
||||
* @property-read Collection<int, DhlShipment> $dhlReturnShipments
|
||||
* @property-read int|null $dhl_return_shipments_count
|
||||
* @property-read \Illuminate\Database\Eloquent\Collection<int, \Acme\Dhl\Models\DhlShipment> $dhlShipments
|
||||
* @property-read Collection<int, DhlShipment> $dhlShipments
|
||||
* @property-read int|null $dhl_shipments_count
|
||||
*
|
||||
* @mixin \Eloquent
|
||||
|
|
@ -608,7 +613,7 @@ class ShoppingOrder extends Model
|
|||
*/
|
||||
public function isCancellationInvoice()
|
||||
{
|
||||
return \App\Models\UserInvoice::where('shopping_order_id', $this->id)
|
||||
return UserInvoice::where('shopping_order_id', $this->id)
|
||||
->where('cancellation', true)
|
||||
->whereNull('cancellation_id')
|
||||
->exists();
|
||||
|
|
@ -620,7 +625,7 @@ class ShoppingOrder extends Model
|
|||
*/
|
||||
public function getCancellationInvoice()
|
||||
{
|
||||
return \App\Models\UserInvoice::where('shopping_order_id', $this->id)
|
||||
return UserInvoice::where('shopping_order_id', $this->id)
|
||||
->where('cancellation', true)
|
||||
->whereNull('cancellation_id')
|
||||
->first();
|
||||
|
|
@ -710,13 +715,13 @@ class ShoppingOrder extends Model
|
|||
}
|
||||
|
||||
foreach ($tax_split as $key => $value) {
|
||||
$tax_split[$key]['vk_tax'] = number_format($value['vk_tax'], 2);
|
||||
$tax_split[$key]['ek_tax'] = number_format($value['ek_tax'], 2);
|
||||
$tax_split[$key]['vk_tax'] = number_format($value['vk_tax'], 2, '.', '');
|
||||
$tax_split[$key]['ek_tax'] = number_format($value['ek_tax'], 2, '.', '');
|
||||
}
|
||||
|
||||
foreach ($net_split as $key => $value) {
|
||||
$net_split[$key]['vk_net'] = number_format($value['vk_net'], 2);
|
||||
$net_split[$key]['ek_net'] = number_format($value['ek_net'], 2);
|
||||
$net_split[$key]['vk_net'] = number_format($value['vk_net'], 2, '.', '');
|
||||
$net_split[$key]['ek_net'] = number_format($value['ek_net'], 2, '.', '');
|
||||
}
|
||||
}
|
||||
if (! isset($tax_split[16]) && ! isset($tax_split[19])) {
|
||||
|
|
@ -779,10 +784,10 @@ class ShoppingOrder extends Model
|
|||
}
|
||||
|
||||
foreach ($tax_split as $key => $value) {
|
||||
$tax_split[$key] = number_format($value, 2);
|
||||
$tax_split[$key] = number_format($value, 2, '.', '');
|
||||
}
|
||||
foreach ($net_split as $key => $value) {
|
||||
$net_split[$key] = number_format($value, 2);
|
||||
$net_split[$key] = number_format($value, 2, '.', '');
|
||||
}
|
||||
}
|
||||
$this->tax_split = $tax_split;
|
||||
|
|
@ -798,7 +803,7 @@ class ShoppingOrder extends Model
|
|||
$fullname = $this->shopping_user->getFullNameAsArray();
|
||||
$ret = '';
|
||||
$ret .= $fullname['company'] ? $fullname['company'].' | ' : '';
|
||||
$ret .= $fullname['salutation'] ? \App\Services\HTMLHelper::getSalutationLang($fullname['salutation']).' ' : '';
|
||||
$ret .= $fullname['salutation'] ? HTMLHelper::getSalutationLang($fullname['salutation']).' ' : '';
|
||||
$ret .= $fullname['firstname'] ? $fullname['firstname'].' ' : '';
|
||||
$ret .= $fullname['lastname'] ? $fullname['lastname'] : '';
|
||||
$ret .= $fullname['email'] ? ' | '.$fullname['email'].'' : '';
|
||||
|
|
|
|||
|
|
@ -91,6 +91,23 @@ class UserAbo extends Model
|
|||
|
||||
protected $table = 'user_abos';
|
||||
|
||||
/**
|
||||
* Ein gelöschtes Abo darf nicht aktiv bleiben: Softdelete setzt nur
|
||||
* deleted_at, wodurch active=1-Leichen entstehen, die Incentive-,
|
||||
* Statistik- und Ist/Soll-Zahlen auseinanderlaufen lassen. Deshalb wird
|
||||
* active beim Löschen immer mitgesetzt (gilt für Model-Deletes; Query-
|
||||
* Builder-Deletes lösen keine Model-Events aus).
|
||||
*/
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::deleting(function (UserAbo $abo): void {
|
||||
if ($abo->active) {
|
||||
$abo->active = false;
|
||||
$abo->saveQuietly();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected $casts = [
|
||||
'user_id' => 'int',
|
||||
'member_id' => 'int',
|
||||
|
|
@ -105,6 +122,8 @@ class UserAbo extends Model
|
|||
'next_date' => 'datetime',
|
||||
'onetime_reminder_sent_for' => 'date',
|
||||
'cancel_date' => 'datetime',
|
||||
'last_pause_date' => 'date',
|
||||
'pause_count' => 'int',
|
||||
'user_deleted_at' => 'datetime',
|
||||
'carddata' => 'array',
|
||||
'card_update_pending_at' => 'datetime',
|
||||
|
|
@ -133,6 +152,8 @@ class UserAbo extends Model
|
|||
'next_date',
|
||||
'onetime_reminder_sent_for',
|
||||
'cancel_date',
|
||||
'last_pause_date',
|
||||
'pause_count',
|
||||
'user_deleted_at',
|
||||
'card_update_pending_at',
|
||||
];
|
||||
|
|
@ -355,6 +376,11 @@ class UserAbo extends Model
|
|||
return $this->hasMany(UserAboItem::class);
|
||||
}
|
||||
|
||||
public function status_histories()
|
||||
{
|
||||
return $this->hasMany(UserAboStatusHistory::class);
|
||||
}
|
||||
|
||||
public function one_time_items()
|
||||
{
|
||||
return $this->hasMany(UserAboOneTimeItem::class);
|
||||
|
|
|
|||
127
app/Models/UserAboStatusHistory.php
Normal file
127
app/Models/UserAboStatusHistory.php
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Protokoll der Status-Aktionen eines Abos (Pause, Kündigung, Reaktivierung):
|
||||
* wer (view + actor_user_id) hat wann welche Aktion ausgeführt und wie sich das
|
||||
* Ausführungsdatum dadurch verschoben hat.
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $user_abo_id
|
||||
* @property string $action
|
||||
* @property string|null $view
|
||||
* @property int|null $actor_user_id
|
||||
* @property Carbon|null $previous_next_date
|
||||
* @property Carbon|null $new_next_date
|
||||
*/
|
||||
class UserAboStatusHistory extends Model
|
||||
{
|
||||
public const ACTION_PAUSED = 'paused';
|
||||
|
||||
public const ACTION_CANCELLED = 'cancelled';
|
||||
|
||||
public const ACTION_DEACTIVATED = 'deactivated';
|
||||
|
||||
public const ACTION_REACTIVATED = 'reactivated';
|
||||
|
||||
public static $actionLabels = [
|
||||
self::ACTION_PAUSED => 'abo_history.status_paused',
|
||||
self::ACTION_CANCELLED => 'abo_history.status_cancelled',
|
||||
self::ACTION_DEACTIVATED => 'abo_history.status_deactivated',
|
||||
self::ACTION_REACTIVATED => 'abo_history.status_reactivated',
|
||||
];
|
||||
|
||||
public static $actionColors = [
|
||||
self::ACTION_PAUSED => 'warning',
|
||||
self::ACTION_CANCELLED => 'danger',
|
||||
self::ACTION_DEACTIVATED => 'secondary',
|
||||
self::ACTION_REACTIVATED => 'success',
|
||||
];
|
||||
|
||||
/**
|
||||
* Formular-View → Kanal-Label/Farbe der Item-Historie (gleiche Optik im Protokoll).
|
||||
*/
|
||||
public static $viewChannelMap = [
|
||||
'me' => 'user_me',
|
||||
'ot' => 'user_ot',
|
||||
'portal' => 'portal',
|
||||
'admin' => 'admin',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'user_abo_id',
|
||||
'action',
|
||||
'view',
|
||||
'actor_user_id',
|
||||
'previous_next_date',
|
||||
'new_next_date',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'user_abo_id' => 'int',
|
||||
'actor_user_id' => 'int',
|
||||
'previous_next_date' => 'date',
|
||||
'new_next_date' => 'date',
|
||||
];
|
||||
|
||||
public function user_abo(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(UserAbo::class);
|
||||
}
|
||||
|
||||
public function actor(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'actor_user_id');
|
||||
}
|
||||
|
||||
public function getActionBadge(): string
|
||||
{
|
||||
$label = isset(self::$actionLabels[$this->action]) ? __(self::$actionLabels[$this->action]) : $this->action;
|
||||
$color = self::$actionColors[$this->action] ?? 'secondary';
|
||||
|
||||
return '<span class="badge badge-'.$color.'">'.$label.'</span>';
|
||||
}
|
||||
|
||||
public function getChannelBadge(): string
|
||||
{
|
||||
$channel = self::$viewChannelMap[$this->view] ?? 'system';
|
||||
$label = isset(UserAboItemHistory::$channelLabels[$channel]) ? __(UserAboItemHistory::$channelLabels[$channel]) : $channel;
|
||||
$color = UserAboItemHistory::$channelColors[$channel] ?? 'secondary';
|
||||
|
||||
return '<span class="badge badge-outline-'.$color.'">'.$label.'</span>';
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
if ($this->action === self::ACTION_PAUSED && $this->previous_next_date && $this->new_next_date) {
|
||||
return __('abo_history.status_shift', [
|
||||
'from' => $this->previous_next_date->format(\Util::formatDateDB()),
|
||||
'to' => $this->new_next_date->format(\Util::formatDateDB()),
|
||||
]);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
public function getFormattedDate(): string
|
||||
{
|
||||
return $this->created_at ? $this->created_at->format(\Util::formatDateDB().' H:i') : '';
|
||||
}
|
||||
|
||||
public function getActorName(): string
|
||||
{
|
||||
if (! $this->actor) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
$name = trim(($this->actor->account?->first_name ?? '').' '.($this->actor->account?->last_name ?? ''));
|
||||
|
||||
return $name !== '' ? $name : ($this->actor->email ?? '-');
|
||||
}
|
||||
}
|
||||
|
|
@ -2,10 +2,10 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Http\Controllers\Api\KasController;
|
||||
use App\Http\Controllers\Api\KasSLLController;
|
||||
use App\User;
|
||||
use Carbon\Carbon;
|
||||
use Cviebrock\EloquentSluggable\Sluggable;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
|
|
@ -32,9 +32,10 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
|||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property \Illuminate\Support\Carbon|null $deleted_at
|
||||
* @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\UserShopOnSite[] $on_sites
|
||||
* @property-read Collection|UserShopOnSite[] $on_sites
|
||||
* @property-read int|null $on_sites_count
|
||||
* @property-read \App\User $user
|
||||
* @property-read User $user
|
||||
*
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\UserShop findSimilarSlugs($attribute, $config, $slug)
|
||||
* @method static bool|null forceDelete()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\UserShop newModelQuery()
|
||||
|
|
@ -65,16 +66,16 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
|||
* @method static \Illuminate\Database\Query\Builder|\App\Models\UserShop withTrashed()
|
||||
* @method static \Illuminate\Database\Query\Builder|\App\Models\UserShop withoutTrashed()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserShop withUniqueSlugConstraints(\Illuminate\Database\Eloquent\Model $model, string $attribute, array $config, string $slug)
|
||||
*
|
||||
* @property array|null $trans
|
||||
*
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserShop whereTrans($value)
|
||||
*
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class UserShop extends Model
|
||||
{
|
||||
protected $table = 'user_shops';
|
||||
private $is_online = NULL;
|
||||
private $ssl_certificate_sni = NULL;
|
||||
private $ssl_certificate_sni_is_active = NULL;
|
||||
|
||||
protected $casts = [
|
||||
'featured' => 'array',
|
||||
|
|
@ -86,15 +87,17 @@ class UserShop extends Model
|
|||
];
|
||||
|
||||
use SoftDeletes;
|
||||
|
||||
protected $dates = ['deleted_at'];
|
||||
|
||||
use Sluggable;
|
||||
|
||||
public function sluggable() : array
|
||||
|
||||
public function sluggable(): array
|
||||
{
|
||||
return [
|
||||
'slug' => [
|
||||
'source' => 'name'
|
||||
]
|
||||
'source' => 'name',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -103,18 +106,27 @@ class UserShop extends Model
|
|||
return $this->belongsTo('App\User', 'user_id');
|
||||
}
|
||||
|
||||
public function on_sites(){
|
||||
public function on_sites()
|
||||
{
|
||||
return $this->hasMany('App\Models\UserShopOnSite', 'user_shop_id', 'id');
|
||||
}
|
||||
|
||||
public function getActiveDateFormat(){
|
||||
if(!$this->attributes['active_date']){ return ""; }
|
||||
public function getActiveDateFormat()
|
||||
{
|
||||
if (! $this->attributes['active_date']) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return Carbon::parse($this->attributes['active_date'])->format(\Util::formatDateTimeDB());
|
||||
}
|
||||
|
||||
public function getActiveDateFormatSmall(){
|
||||
if(!$this->attributes['active_date']){ return ""; }
|
||||
return Carbon::parse($this->attributes['active_date'])->format("d.m.Y");
|
||||
public function getActiveDateFormatSmall()
|
||||
{
|
||||
if (! $this->attributes['active_date']) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return Carbon::parse($this->attributes['active_date'])->format('d.m.Y');
|
||||
}
|
||||
|
||||
public function getLang($key, $default = true)
|
||||
|
|
@ -123,6 +135,7 @@ class UserShop extends Model
|
|||
if ($lang == 'de') {
|
||||
return $this->{$key};
|
||||
}
|
||||
|
||||
return $this->getTrans($key, $lang, $default);
|
||||
}
|
||||
|
||||
|
|
@ -131,126 +144,70 @@ class UserShop extends Model
|
|||
if ($lang == 'de') {
|
||||
return $this->{$key};
|
||||
}
|
||||
if(!empty($this->trans[$lang][$key])){
|
||||
if (! empty($this->trans[$lang][$key])) {
|
||||
return $this->trans[$lang][$key];
|
||||
}
|
||||
if($default){
|
||||
return !empty($this->{$key}) ? $this->{$key} : '';
|
||||
if ($default) {
|
||||
return ! empty($this->{$key}) ? $this->{$key} : '';
|
||||
}
|
||||
return "";
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
public function getSubdomain($session=true)
|
||||
public function getSubdomain($session = true)
|
||||
{
|
||||
if($session && \Session::has('user_shop_domain')){
|
||||
if ($session && \Session::has('user_shop_domain')) {
|
||||
return \Session::get('user_shop_domain');
|
||||
}
|
||||
return config('app.protocol').$this->attributes['slug'].".".config('app.domain').config('app.tld_care');
|
||||
|
||||
return config('app.protocol').$this->attributes['slug'].'.'.config('app.domain').config('app.tld_care');
|
||||
}
|
||||
|
||||
public function getSubdomainStatus()
|
||||
public function getSubdomainAvailable()
|
||||
{
|
||||
if($this->is_online !== NULL){
|
||||
return $this->is_online;
|
||||
}
|
||||
$rCurlHandle = curl_init($this->attributes['slug'].'.mivita.care');
|
||||
|
||||
$kas = new KasController();
|
||||
$domain = 'mivita.care';
|
||||
$pra = array(
|
||||
'subdomain_name' => $this->attributes['slug'].".".$domain,
|
||||
'domain_name' => $domain,
|
||||
);
|
||||
curl_setopt($rCurlHandle, CURLOPT_CONNECTTIMEOUT, 10);
|
||||
curl_setopt($rCurlHandle, CURLOPT_HEADER, true);
|
||||
curl_setopt($rCurlHandle, CURLOPT_NOBODY, true);
|
||||
curl_setopt($rCurlHandle, CURLOPT_RETURNTRANSFER, true);
|
||||
|
||||
//check if exisist
|
||||
$subdomain = $kas->action('get_subdomains', $pra);
|
||||
if(is_soap_fault($subdomain)){
|
||||
$strResponse = curl_exec($rCurlHandle);
|
||||
|
||||
curl_close($rCurlHandle);
|
||||
|
||||
if (! $strResponse) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
if(!empty($subdomain[0]['is_active']) && $subdomain[0]['is_active'] == 'Y'){
|
||||
$this->is_online = true;
|
||||
}else{
|
||||
$this->is_online = false;
|
||||
}
|
||||
|
||||
if(!empty($subdomain[0]['ssl_certificate_sni']) && $subdomain[0]['ssl_certificate_sni'] == 'Y'){
|
||||
$this->ssl_certificate_sni = true;
|
||||
}else{
|
||||
$this->ssl_certificate_sni = false;
|
||||
$this->userShopAddSSL($subdomain[0]['subdomain_name']);
|
||||
}
|
||||
|
||||
if(!empty($subdomain[0]['ssl_certificate_sni_is_active']) && $subdomain[0]['ssl_certificate_sni_is_active'] == 'j'){
|
||||
$this->ssl_certificate_sni_is_active = true;
|
||||
}else{
|
||||
$this->ssl_certificate_sni_is_active = false;
|
||||
}
|
||||
|
||||
return $this->is_online;
|
||||
}
|
||||
|
||||
public function userShopAddSSL($full_subdomain_name)
|
||||
{
|
||||
$kas = new KasController();
|
||||
$ssl = KasSLLController::getApiSSLParameter();
|
||||
$pra = array(
|
||||
'hostname' => $full_subdomain_name
|
||||
);
|
||||
$pra = array_merge($pra, $ssl);
|
||||
$value = $kas->action('update_ssl', $pra);
|
||||
return $value;
|
||||
}
|
||||
public function getSubdomainSslSin ()
|
||||
{
|
||||
return $this->ssl_certificate_sni;
|
||||
}
|
||||
public function getSubdomainSslSinActive ()
|
||||
{
|
||||
return $this->ssl_certificate_sni_is_active;
|
||||
}
|
||||
public function getSubdomainAvailable ()
|
||||
{
|
||||
$rCurlHandle = curl_init ( $this->attributes['slug'].".mivita.care" );
|
||||
|
||||
curl_setopt ( $rCurlHandle, CURLOPT_CONNECTTIMEOUT, 10 );
|
||||
curl_setopt ( $rCurlHandle, CURLOPT_HEADER, TRUE );
|
||||
curl_setopt ( $rCurlHandle, CURLOPT_NOBODY, TRUE );
|
||||
curl_setopt ( $rCurlHandle, CURLOPT_RETURNTRANSFER, TRUE );
|
||||
|
||||
$strResponse = curl_exec ( $rCurlHandle );
|
||||
|
||||
curl_close ( $rCurlHandle );
|
||||
|
||||
if ( !$strResponse )
|
||||
{
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
$url = "http://".$this->attributes['slug'].".mivita.care/domain/check";
|
||||
if(@file_get_contents($url) != 'checked'){
|
||||
return FALSE;
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function isImage(){
|
||||
if(empty($this->attributes['filename']) || @$this->attributes['filename'] == null || @$this->attributes['filename'] == ""){
|
||||
return false;
|
||||
}
|
||||
if(!\Storage::disk('public')->has('images/shop/'.$this->filename)){
|
||||
$url = 'http://'.$this->attributes['slug'].'.mivita.care/domain/check';
|
||||
if (@file_get_contents($url) != 'checked') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getImage(){
|
||||
if($this->isImage()){
|
||||
public function isImage()
|
||||
{
|
||||
if (empty($this->attributes['filename']) || @$this->attributes['filename'] == null || @$this->attributes['filename'] == '') {
|
||||
return false;
|
||||
}
|
||||
if (! \Storage::disk('public')->has('images/shop/'.$this->filename)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getImage()
|
||||
{
|
||||
if ($this->isImage()) {
|
||||
$link = 'images/shop/'.$this->filename;
|
||||
|
||||
return '/storage/'.$link.'?=lm='.\Storage::disk('public')->lastModified($link);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
namespace App\Repositories;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\UserAbo;
|
||||
use App\Models\UserAboStatusHistory;
|
||||
use App\Services\AboHelper;
|
||||
use Carbon\Carbon;
|
||||
|
||||
|
|
@ -19,7 +21,7 @@ class AboRepository extends BaseRepository
|
|||
*/
|
||||
public static function getLockDaysChange(): int
|
||||
{
|
||||
$days = (int) \App\Models\Setting::getContentBySlug('abo-lock-days-change');
|
||||
$days = (int) Setting::getContentBySlug('abo-lock-days-change');
|
||||
|
||||
return $days > 0 ? $days : self::DEFAULT_LOCK_DAYS_CHANGE;
|
||||
}
|
||||
|
|
@ -31,7 +33,7 @@ class AboRepository extends BaseRepository
|
|||
*/
|
||||
public static function getLockDaysPauseCancel(): int
|
||||
{
|
||||
$days = (int) \App\Models\Setting::getContentBySlug('abo-lock-days-pause-cancel');
|
||||
$days = (int) Setting::getContentBySlug('abo-lock-days-pause-cancel');
|
||||
|
||||
return $days > 0 ? $days : self::DEFAULT_LOCK_DAYS_PAUSE_CANCEL;
|
||||
}
|
||||
|
|
@ -51,6 +53,13 @@ class AboRepository extends BaseRepository
|
|||
if (isset($data['action'])) {
|
||||
if ($data['action'] === 'abo_update_settings') {
|
||||
if ($this->validate($data)) {
|
||||
// Pause ist eine eigenständige Aktion: sie verschiebt nur next_date
|
||||
// und darf nicht von der anschließenden Liefertag-Neuberechnung
|
||||
// überschrieben werden.
|
||||
if (isset($data['abo_pause']) && $data['abo_pause'] == 'true') {
|
||||
return $this->pauseOneMonth($data);
|
||||
}
|
||||
|
||||
$this->updateStatus($data);
|
||||
$this->model->abo_interval = (int) $data['abo_interval'];
|
||||
$nextDate = $this->isAdminUpdate($data)
|
||||
|
|
@ -60,7 +69,7 @@ class AboRepository extends BaseRepository
|
|||
$this->model->save();
|
||||
|
||||
$daysUntilNext = AboHelper::calendarDaysUntil(now(), $nextDate);
|
||||
\Session()->flash('alert-warning', __('abo.warning_next_date_info', ['days' => $daysUntilNext, 'date' => $nextDate->format('d.m.Y')]));
|
||||
\Session()->flash('alert-warning', __('abo.warning_next_date_info', ['days' => $daysUntilNext, 'date' => $nextDate->format(\Util::formatDateDB())]));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
@ -80,9 +89,18 @@ class AboRepository extends BaseRepository
|
|||
|
||||
// Handle cancellation
|
||||
if (isset($data['abo_cancel']) && $data['abo_cancel'] == 'true') {
|
||||
// Mindestlaufzeit serverseitig durchsetzen — die UI blendet den Button nur aus
|
||||
if (! $isAdminUpdate && ! AboHelper::canCancelAbo($this->model, $data['view'] ?? 'user')) {
|
||||
\Session()->flash('alert-error', __('abo.info_min_duration_orders_left', [
|
||||
'count' => max(0, (int) AboHelper::getAboMinDuration() - $this->model->getCountPaidOrders()),
|
||||
]));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Sperre: 3 Tage vor Ausführung kann nicht mehr pausiert/gekündigt werden
|
||||
if (! $isAdminUpdate && $this->model->next_date) {
|
||||
$daysUntil = (int) now()->diffInDays(\Carbon\Carbon::parse($this->model->next_date), false);
|
||||
$daysUntil = (int) now()->diffInDays(Carbon::parse($this->model->next_date), false);
|
||||
if ($daysUntil >= 0 && $daysUntil < self::getLockDaysPauseCancel()) {
|
||||
\Session()->flash('alert-error', __('abo.error_cancel_locked', ['days' => $daysUntil, 'lock' => self::getLockDaysPauseCancel()]));
|
||||
|
||||
|
|
@ -96,13 +114,15 @@ class AboRepository extends BaseRepository
|
|||
$this->model->cancel_date = now();
|
||||
$this->model->save();
|
||||
|
||||
$this->logStatusHistory(UserAboStatusHistory::ACTION_CANCELLED, $data);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$active = (isset($data['abo_is_active']) && $data['abo_is_active']) ? true : false;
|
||||
// Sperre: 3 Tage vor Ausführung kann nicht mehr pausiert werden
|
||||
if (! $isAdminUpdate && $this->model->active && ! $active && $this->model->next_date) {
|
||||
$daysUntil = (int) now()->diffInDays(\Carbon\Carbon::parse($this->model->next_date), false);
|
||||
$daysUntil = (int) now()->diffInDays(Carbon::parse($this->model->next_date), false);
|
||||
if ($daysUntil >= 0 && $daysUntil < self::getLockDaysPauseCancel()) {
|
||||
\Session()->flash('alert-error', __('abo.error_pause_locked', ['days' => $daysUntil, 'lock' => self::getLockDaysPauseCancel()]));
|
||||
|
||||
|
|
@ -115,6 +135,8 @@ class AboRepository extends BaseRepository
|
|||
$this->model->status = 6; // inactive
|
||||
$this->model->active = false;
|
||||
$this->model->save();
|
||||
|
||||
$this->logStatusHistory(UserAboStatusHistory::ACTION_DEACTIVATED, $data);
|
||||
}
|
||||
}
|
||||
if (! $this->model->active && $active) {
|
||||
|
|
@ -122,11 +144,88 @@ class AboRepository extends BaseRepository
|
|||
$this->model->status = 2; // okay
|
||||
$this->model->active = true;
|
||||
$this->model->save();
|
||||
|
||||
$this->logStatusHistory(UserAboStatusHistory::ACTION_REACTIVATED, $data);
|
||||
}
|
||||
}
|
||||
$this->model->active = $active;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pausiert das Abo um genau einen Ausführungs-Zyklus: next_date wandert auf den
|
||||
* gleichen Liefertag des Folgemonats, das Abo bleibt aktiv (Status 2).
|
||||
* Regeln (außer Admin): Sperrfrist vor der Ausführung, Mindestlaufzeit und
|
||||
* max. 1 Pause pro X Monate ({@see AboHelper::canPauseAbo()}).
|
||||
*/
|
||||
private function pauseOneMonth(array $data): bool
|
||||
{
|
||||
// Zeilen-Lock + frisches Modell: zwei parallele Pause-Requests dürfen next_date
|
||||
// nicht beide auf Basis des alten Standes verschieben (Doppel-Pause).
|
||||
return \DB::transaction(function () use ($data): bool {
|
||||
$this->model = UserAbo::whereKey($this->model->id)->lockForUpdate()->firstOrFail();
|
||||
|
||||
return $this->pauseOneMonthLocked($data);
|
||||
});
|
||||
}
|
||||
|
||||
private function pauseOneMonthLocked(array $data): bool
|
||||
{
|
||||
$isAdminUpdate = $this->isAdminUpdate($data);
|
||||
|
||||
if (! $this->model->active || (int) $this->model->status !== 2 || ! $this->model->getRawOriginal('next_date')) {
|
||||
\Session()->flash('alert-error', __('abo.error_pause_not_active'));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $isAdminUpdate) {
|
||||
$daysUntil = (int) now()->diffInDays(Carbon::parse($this->model->getRawOriginal('next_date')), false);
|
||||
if ($daysUntil >= 0 && $daysUntil < self::getLockDaysPauseCancel()) {
|
||||
\Session()->flash('alert-error', __('abo.error_pause_locked', ['days' => $daysUntil, 'lock' => self::getLockDaysPauseCancel()]));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! AboHelper::canCancelAbo($this->model, $data['view'] ?? 'user')) {
|
||||
\Session()->flash('alert-error', __('abo.info_min_duration_orders_left', ['count' => (int) AboHelper::getAboMinDuration() - $this->model->getCountPaidOrders()]));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (($nextPossible = AboHelper::nextPossiblePauseDate($this->model)) !== null) {
|
||||
\Session()->flash('alert-error', __('abo.error_pause_interval', ['months' => AboHelper::getPauseIntervalMonths(), 'date' => $nextPossible->format(\Util::formatDateDB())]));
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$previousNextDate = Carbon::parse($this->model->getRawOriginal('next_date'));
|
||||
$newNextDate = AboHelper::setNextDate($previousNextDate, (int) $this->model->abo_interval);
|
||||
|
||||
$this->model->next_date = $newNextDate;
|
||||
$this->model->last_pause_date = now();
|
||||
$this->model->pause_count = (int) $this->model->pause_count + 1;
|
||||
$this->model->save();
|
||||
|
||||
$this->logStatusHistory(UserAboStatusHistory::ACTION_PAUSED, $data, $previousNextDate, $newNextDate);
|
||||
|
||||
\Session()->flash('alert-success', __('abo.pause_success', ['date' => $newNextDate->format(\Util::formatDateDB())]));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function logStatusHistory(string $action, array $data, ?Carbon $previousNextDate = null, ?Carbon $newNextDate = null): void
|
||||
{
|
||||
UserAboStatusHistory::create([
|
||||
'user_abo_id' => $this->model->id,
|
||||
'action' => $action,
|
||||
'view' => $data['view'] ?? null,
|
||||
'actor_user_id' => \Auth::id(),
|
||||
'previous_next_date' => $previousNextDate,
|
||||
'new_next_date' => $newNextDate,
|
||||
]);
|
||||
}
|
||||
|
||||
private function validate($data)
|
||||
{
|
||||
if ($data['view'] !== 'admin' && $data['view'] !== 'portal') {
|
||||
|
|
@ -151,7 +250,7 @@ class AboRepository extends BaseRepository
|
|||
return false;
|
||||
}
|
||||
}
|
||||
if (! isset($data['abo_interval']) || ! in_array((int) $data['abo_interval'], \App\Models\UserAbo::$aboDeliveryDays, true)) {
|
||||
if (! isset($data['abo_interval']) || ! in_array((int) $data['abo_interval'], UserAbo::$aboDeliveryDays, true)) {
|
||||
\Session()->flash('alert-error', __('abo.error_abo_interval'));
|
||||
|
||||
return false;
|
||||
|
|
@ -181,7 +280,7 @@ class AboRepository extends BaseRepository
|
|||
if ($intervalChanged) {
|
||||
// Sperre: X Tage vor nächster Ausführung kein Liefertag-Wechsel mehr (Pakete werden vorgepackt)
|
||||
if ($this->model->next_date) {
|
||||
$daysUntilExecution = (int) now()->diffInDays(\Carbon\Carbon::parse($this->model->next_date), false);
|
||||
$daysUntilExecution = (int) now()->diffInDays(Carbon::parse($this->model->next_date), false);
|
||||
if ($daysUntilExecution >= 0 && $daysUntilExecution < self::getLockDaysChange()) {
|
||||
\Session()->flash('alert-error', __('abo.error_change_locked', ['days' => $daysUntilExecution, 'lock' => self::getLockDaysChange()]));
|
||||
|
||||
|
|
@ -208,12 +307,12 @@ class AboRepository extends BaseRepository
|
|||
* Falls next_date bereits in einem zukünftigen Monat liegt, wird der Monatsanfang
|
||||
* dieses Monats als Referenz verwendet, sodass der neue Tag im selben Monat landet.
|
||||
*/
|
||||
private function calculateNewNextDate(int $aboInterval): \Carbon\Carbon
|
||||
private function calculateNewNextDate(int $aboInterval): Carbon
|
||||
{
|
||||
$referenceDate = now();
|
||||
|
||||
if ($this->model->next_date) {
|
||||
$currentNextDate = \Carbon\Carbon::parse($this->model->next_date);
|
||||
$currentNextDate = Carbon::parse($this->model->next_date);
|
||||
|
||||
if ($currentNextDate->format('Y-m') > now()->format('Y-m')) {
|
||||
$referenceDate = $currentNextDate->startOfMonth();
|
||||
|
|
|
|||
|
|
@ -25,8 +25,8 @@ class CreditRepository extends BaseRepository
|
|||
|
||||
public function create($request = [])
|
||||
{
|
||||
// need invoice $data
|
||||
$number = Credit::getCreditNumber();
|
||||
// Nummer atomar reservieren (gemeinsamer Nummernkreis mit Rechnungen)
|
||||
$number = Credit::makeNextCreditNumber();
|
||||
$credit_date = isset($request['credit_date']) ? $request['credit_date'] : \Carbon::now()->format('d.m.Y');
|
||||
$credit_send_mail = isset($request['credit_send_mail']) ? true : false;
|
||||
$credit_number = Credit::createCreditNumber($number, $credit_date);
|
||||
|
|
@ -131,8 +131,6 @@ class CreditRepository extends BaseRepository
|
|||
|
||||
private function finishUserCredit($user_credit_id, $user_credit_items)
|
||||
{
|
||||
// next credits
|
||||
Credit::makeNextCreditNumber();
|
||||
// mark as payed
|
||||
// $UserCreditItems = UserCreditItem::where('user_id', $this->model->id)->wherePaid(false)->get();
|
||||
foreach ($user_credit_items as $user_credit_item) {
|
||||
|
|
|
|||
|
|
@ -2,20 +2,16 @@
|
|||
|
||||
namespace App\Repositories;
|
||||
|
||||
use Util;
|
||||
use Request;
|
||||
use App\User;
|
||||
use Validator;
|
||||
use App\Services\UserUtil;
|
||||
use App\Models\UserAccount;
|
||||
use App\Models\PaymentMethod;
|
||||
use App\Models\UserAccount;
|
||||
use App\Services\UserUtil;
|
||||
use App\Services\VatIdService;
|
||||
use App\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use App\Http\Controllers\Api\KasController;
|
||||
use Validator;
|
||||
|
||||
class UserRepository extends BaseRepository
|
||||
{
|
||||
|
||||
|
||||
public function __construct(User $model)
|
||||
{
|
||||
$this->model = $model;
|
||||
|
|
@ -24,7 +20,7 @@ class UserRepository extends BaseRepository
|
|||
public function update($data)
|
||||
{
|
||||
|
||||
if ($data['user_id'] === "new" || $data['user_id'] == 0) {
|
||||
if ($data['user_id'] === 'new' || $data['user_id'] == 0) {
|
||||
$this->model = User::create([
|
||||
'email' => $data['email'],
|
||||
'password' => env('APP_KEY'),
|
||||
|
|
@ -35,16 +31,16 @@ class UserRepository extends BaseRepository
|
|||
$this->model = $this->getById($data['user_id']);
|
||||
}
|
||||
|
||||
if (!$this->model->account_id) {
|
||||
$account = new UserAccount();
|
||||
if (! $this->model->account_id) {
|
||||
$account = new UserAccount;
|
||||
} else {
|
||||
$account = $this->model->account;
|
||||
}
|
||||
|
||||
$data['same_as_billing'] = !isset($data['same_as_billing']) ? 0 : 1;
|
||||
$data['same_as_billing'] = ! isset($data['same_as_billing']) ? 0 : 1;
|
||||
$account->fill($data)->save();
|
||||
|
||||
if (!$this->model->account_id) {
|
||||
if (! $this->model->account_id) {
|
||||
$this->model->account_id = $account->id;
|
||||
$this->model->save();
|
||||
}
|
||||
|
|
@ -73,7 +69,6 @@ class UserRepository extends BaseRepository
|
|||
$this->model->payment_methods = PaymentMethod::getDefaultAsArray();
|
||||
$this->model->save();
|
||||
|
||||
|
||||
return $this->model;
|
||||
}
|
||||
|
||||
|
|
@ -91,31 +86,37 @@ class UserRepository extends BaseRepository
|
|||
{
|
||||
|
||||
if (isset($data['action']) && $data['action'] == 'reverse_charge_validate') {
|
||||
$rules = array(
|
||||
$rules = [
|
||||
'tax_identification_number' => 'required',
|
||||
);
|
||||
];
|
||||
$validator = Validator::make($data, $rules);
|
||||
if ($validator->fails()) {
|
||||
$data = [
|
||||
'user' => $user,
|
||||
];
|
||||
return redirect($route . '#user-vat-validation')->withErrors($validator)->withInput($data);
|
||||
|
||||
return redirect($route.'#user-vat-validation')->withErrors($validator)->withInput($data);
|
||||
}
|
||||
$ret = $this->reverse_charge_activate($data, $user);
|
||||
if ($ret === 'error') {
|
||||
if ($ret === 'error' || $ret === 'error_german') {
|
||||
$validator = Validator::make($data, []);
|
||||
$validator->errors()->add('tax_identification_number_validated', __('msg.VATID_could_not_be_validated'));
|
||||
$message = $ret === 'error_german'
|
||||
? __('msg.VATID_german_not_allowed')
|
||||
: __('msg.VATID_could_not_be_validated');
|
||||
$validator->errors()->add('tax_identification_number_validated', $message);
|
||||
$data['reverse_charge'] = 0;
|
||||
$data = [
|
||||
'user' => $user,
|
||||
];
|
||||
return redirect($route . '#user-vat-validation')->withErrors($validator)->withInput($data);
|
||||
|
||||
return redirect($route.'#user-vat-validation')->withErrors($validator)->withInput($data);
|
||||
}
|
||||
if ($ret === 'valid') {
|
||||
\Session()->flash('alert-success', __('msg.VATID_successfully_entered'));
|
||||
return redirect($route . '#user-vat-validation')->withInput($data);
|
||||
|
||||
return redirect($route . '#user-vat-validation')->withInput($data);
|
||||
return redirect($route.'#user-vat-validation')->withInput($data);
|
||||
|
||||
return redirect($route.'#user-vat-validation')->withInput($data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -125,12 +126,13 @@ class UserRepository extends BaseRepository
|
|||
if (isset($data['action']) && $data['action'] == 'reverse_charge_delete') {
|
||||
$user->account->tax_identification_number = '';
|
||||
$user->account->reverse_charge = 0;
|
||||
$user->account->reverse_charge_code = null;
|
||||
$user->account->reverse_charge_code = null;
|
||||
$user->account->reverse_charge_valid = null;
|
||||
$user->account->save();
|
||||
$data['tax_identification_number'] = '';
|
||||
\Session()->flash('alert-success', __('msg.reverse_charge_procedure_and_VATID_deleted'));
|
||||
return redirect($route . '#user-vat-validation')->withInput($data);
|
||||
|
||||
return redirect($route.'#user-vat-validation')->withInput($data);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -165,42 +167,30 @@ class UserRepository extends BaseRepository
|
|||
'SI' => 'SI-Slowenien',
|
||||
'SK' => 'SK-Slowakei',
|
||||
'XI' => 'XI-Nordirland', */
|
||||
$countryCode = 'DE';
|
||||
$accountCountryCode = 'DE';
|
||||
|
||||
if ($user->account->country_id) {
|
||||
$countryCode = $user->account->country->code;
|
||||
$accountCountryCode = $user->account->country->code;
|
||||
}
|
||||
|
||||
$vatid = str_replace(array(' ', '.', '-', ',', ', '), '', trim($data['tax_identification_number']));
|
||||
$cc = substr($vatid, 0, 2);
|
||||
$vatNo = substr($vatid, 2);
|
||||
$vatService = app(VatIdService::class);
|
||||
$vatid = $vatService->normalize($data['tax_identification_number']);
|
||||
$countryCode = $vatService->extractCountryCode($vatid, $accountCountryCode);
|
||||
|
||||
$options = [
|
||||
'cache_wsdl' => WSDL_CACHE_NONE,
|
||||
'trace' => 1,
|
||||
'stream_context' => stream_context_create(
|
||||
[
|
||||
'ssl' => [
|
||||
'verify_peer' => false,
|
||||
'verify_peer_name' => false,
|
||||
'allow_self_signed' => true
|
||||
]
|
||||
]
|
||||
)
|
||||
];
|
||||
if ($vatService->isGermanVatId($vatid) || $countryCode === 'DE') {
|
||||
return 'error_german';
|
||||
}
|
||||
|
||||
$client = new \SoapClient("https://ec.europa.eu/taxation_customs/vies/checkVatService.wsdl", $options);
|
||||
$result = $client->checkVat(['countryCode' => $countryCode, 'vatNumber' => $vatNo]);
|
||||
|
||||
if ($result->valid == true) {
|
||||
if ($vatService->checkVies($vatid, $accountCountryCode) === VatIdService::CHECK_VALID) {
|
||||
$user->account->tax_identification_number = $data['tax_identification_number'];
|
||||
$user->account->reverse_charge = 1;
|
||||
$user->account->reverse_charge_code = $countryCode;
|
||||
$user->account->reverse_charge_valid = now();
|
||||
$user->account->save();
|
||||
|
||||
return 'valid';
|
||||
} else {
|
||||
return 'error';
|
||||
}
|
||||
|
||||
return 'error';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
250
app/Services/Abo/PaylineForecastService.php
Normal file
250
app/Services/Abo/PaylineForecastService.php
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Abo;
|
||||
|
||||
use App\Models\UserAbo;
|
||||
use App\Models\UserSalesVolume;
|
||||
use App\Services\Backoffice\BackofficeDashboardService;
|
||||
use App\Services\Backoffice\SalesVolumePoints;
|
||||
use App\Services\BusinessPlan\LiveCommissionService;
|
||||
use App\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* Payline-bezogene Abo-Kennzahlen (Konzept B8 + B10):
|
||||
*
|
||||
* - Payline = die ersten N Ebenen der Downline; N ("paylines") kommt aus dem
|
||||
* Provisionslevel des Users (Live-TreeCalcBot bzw. UserBusiness-Snapshot).
|
||||
* - B8 aboTurnover(): Abo-Gesamtumsatz der Payline als Soll (Abo-Punkte-Bestand
|
||||
* pro Zyklus) und Ist (im Monat abgerechnete Abo-Punkte, F10).
|
||||
* - B10 forecastDays(): voraussichtlicher Payline-Gesamtumsatz zu den festen
|
||||
* Ausführungsterminen 5./10./20./25. — Basis (Kundenumsatz + Payline-Punkte
|
||||
* Stand heute) plus die bis zum jeweiligen Termin noch anstehenden
|
||||
* Abo-Punkte, kumuliert (F11).
|
||||
*/
|
||||
class PaylineForecastService
|
||||
{
|
||||
/**
|
||||
* F12 (dokumentierte Annahme, Kunde entscheidet nach Umsetzung): Die eigenen
|
||||
* Abos und eigenen Kundenabos des Beraters zählen in der Prognose MIT, weil
|
||||
* die Basis der Formel den aktuellen Kundenumsatz enthält und kommende
|
||||
* eigene Ausführungen genau diesen Anteil erhöhen. Über diesen Schalter
|
||||
* ohne Umbau umkehrbar.
|
||||
*/
|
||||
public const INCLUDE_OWN_ABOS = true;
|
||||
|
||||
private const CACHE_HOURS = 6;
|
||||
|
||||
public function __construct(
|
||||
private BackofficeDashboardService $dashboardService,
|
||||
private LiveCommissionService $commissionService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Payline-Kontext (Anzahl bezahlter Ebenen + Mitglieder-IDs), 6 h gecacht —
|
||||
* die Payline-Tiefe kommt aus dem gemeinsamen Live-Lauf, die Abo-Summen
|
||||
* bleiben live. Ein fehlgeschlagener Live-Lauf wird nicht gecacht, damit
|
||||
* ein transienter Fehler die Karte nicht 6 h lang leert.
|
||||
*
|
||||
* @return array{paylines: int, member_ids: int[]}
|
||||
*/
|
||||
public function paylineContext(User $user, int $month, int $year): array
|
||||
{
|
||||
$cacheKey = "payline_context_{$user->id}_{$month}_{$year}";
|
||||
$cached = Cache::get($cacheKey);
|
||||
if (is_array($cached)) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
[$context, $failed] = $this->buildPaylineContext($user, $month, $year);
|
||||
|
||||
if (! $failed) {
|
||||
Cache::put($cacheKey, $context, now()->addHours(self::CACHE_HOURS));
|
||||
}
|
||||
|
||||
return $context;
|
||||
}
|
||||
|
||||
/**
|
||||
* B8: Abo-Gesamtumsatz in der Payline — Soll und Ist, plus die Anzahl der
|
||||
* dahinterstehenden Abos (Berater- und Kundenabos, Kundenwunsch 2026-07-24).
|
||||
*
|
||||
* @return array{paylines: int, members: int, target: float, actual: float, team_abo_count: int, team_customer_abo_count: int}
|
||||
*/
|
||||
public function aboTurnover(User $user, int $month, int $year): array
|
||||
{
|
||||
$context = $this->paylineContext($user, $month, $year);
|
||||
$memberIds = $context['member_ids'];
|
||||
|
||||
return [
|
||||
'paylines' => $context['paylines'],
|
||||
'members' => count($memberIds),
|
||||
'target' => $this->aboTargetPoints($memberIds),
|
||||
'actual' => $this->aboActualPoints($memberIds, $month, $year),
|
||||
'team_abo_count' => $this->aboCount($memberIds, 'me', 'user_id'),
|
||||
'team_customer_abo_count' => $this->aboCount($memberIds, 'ot', 'member_id'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Anzahl aktiver Abos der Payline-Mitglieder (gleiche Filter wie Soll).
|
||||
*
|
||||
* @param int[] $memberIds
|
||||
*/
|
||||
private function aboCount(array $memberIds, string $isFor, string $userColumn): int
|
||||
{
|
||||
if ($memberIds === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return UserAbo::query()
|
||||
->where('active', true)
|
||||
->whereNotIn('status', [4, 5, 6])
|
||||
->where('is_for', $isFor)
|
||||
->whereIn($userColumn, $memberIds)
|
||||
->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* B10: Prognose je Ausführungstermin des LAUFENDEN Monats. Basis ist der
|
||||
* heutige Stand (Kundenumsatz + Payline-Punkte, vom Aufrufer übergeben);
|
||||
* dazu kommen kumuliert die Abo-Punkte aller noch offenen Ausführungen bis
|
||||
* zum jeweiligen Termin (next_date > heute, im laufenden Monat). Bereits
|
||||
* ausgeführte Abos stecken im Ist-Umsatz und werden nicht doppelt gezählt.
|
||||
*
|
||||
* @return array{base: float, days: array<int, array{day: int, date: string, upcoming: float, total: float, is_past: bool}>}
|
||||
*/
|
||||
public function forecastDays(User $user, float $basePoints): array
|
||||
{
|
||||
$today = Carbon::today();
|
||||
$context = $this->paylineContext($user, (int) $today->month, (int) $today->year);
|
||||
|
||||
$upcomingAbos = $this->upcomingAboQuery($user, $context['member_ids'], $today)
|
||||
->with('user_abo_items.product')
|
||||
->get();
|
||||
|
||||
$pointsByDay = [];
|
||||
foreach ($upcomingAbos as $abo) {
|
||||
$day = (int) Carbon::parse($abo->getRawOriginal('next_date'))->day;
|
||||
$pointsByDay[$day] = ($pointsByDay[$day] ?? 0.0) + (float) $abo->getTotalPoints();
|
||||
}
|
||||
|
||||
$days = [];
|
||||
foreach (UserAbo::$aboDeliveryDays as $deliveryDay) {
|
||||
$upcoming = 0.0;
|
||||
foreach ($pointsByDay as $day => $points) {
|
||||
if ($day <= $deliveryDay) {
|
||||
$upcoming += $points;
|
||||
}
|
||||
}
|
||||
|
||||
$days[] = [
|
||||
'day' => $deliveryDay,
|
||||
'date' => $today->copy()->startOfMonth()->addDays($deliveryDay - 1)->format(\Util::formatDateDB()),
|
||||
'upcoming' => $upcoming,
|
||||
'total' => $basePoints + $upcoming,
|
||||
'is_past' => $deliveryDay < (int) $today->day,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'base' => $basePoints,
|
||||
'days' => $days,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: array{paylines: int, member_ids: int[]}, 1: bool} [Kontext, Lauf fehlgeschlagen]
|
||||
*/
|
||||
private function buildPaylineContext(User $user, int $month, int $year): array
|
||||
{
|
||||
$snapshot = $this->commissionService->snapshot($user, $month, $year);
|
||||
$paylines = $snapshot['paylines'];
|
||||
|
||||
if ($paylines <= 0) {
|
||||
return [['paylines' => 0, 'member_ids' => []], (bool) ($snapshot['failed'] ?? false)];
|
||||
}
|
||||
|
||||
$memberIds = collect($this->dashboardService->lineBuckets($user->id))
|
||||
->filter(fn ($users, int $line) => $line <= $paylines)
|
||||
->flatMap(fn ($users) => $users->pluck('id'))
|
||||
->map(fn ($id) => (int) $id)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return [['paylines' => $paylines, 'member_ids' => $memberIds], false];
|
||||
}
|
||||
|
||||
/**
|
||||
* Soll: Abo-Punkte-Bestand (Produktpunkte × Menge, Hauptartikel comp = 0)
|
||||
* aller aktiven Abos der Payline-Mitglieder — Berater-Abos und Kundenabos.
|
||||
*
|
||||
* @param int[] $memberIds
|
||||
*/
|
||||
private function aboTargetPoints(array $memberIds): float
|
||||
{
|
||||
if ($memberIds === []) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return (float) UserAbo::query()
|
||||
->join('user_abo_items', 'user_abo_items.user_abo_id', '=', 'user_abos.id')
|
||||
->join('products', 'products.id', '=', 'user_abo_items.product_id')
|
||||
->whereNull('products.deleted_at')
|
||||
->where('user_abos.active', true)
|
||||
->whereNotIn('user_abos.status', [4, 5, 6])
|
||||
->where('user_abo_items.comp', 0)
|
||||
->where(function (Builder $query) use ($memberIds) {
|
||||
$query->where(fn (Builder $inner) => $inner->where('user_abos.is_for', 'me')->whereIn('user_abos.user_id', $memberIds))
|
||||
->orWhere(fn (Builder $inner) => $inner->where('user_abos.is_for', 'ot')->whereIn('user_abos.member_id', $memberIds));
|
||||
})
|
||||
->selectRaw('COALESCE(SUM(products.points * user_abo_items.qty), 0) as points')
|
||||
->value('points');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ist: im Monat abgerechnete Abo-Punkte der Payline-Mitglieder.
|
||||
*
|
||||
* @param int[] $memberIds
|
||||
*/
|
||||
private function aboActualPoints(array $memberIds, int $month, int $year): float
|
||||
{
|
||||
if ($memberIds === []) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return (float) UserSalesVolume::query()
|
||||
->leftJoin('shopping_orders', 'shopping_orders.id', '=', 'user_sales_volumes.shopping_order_id')
|
||||
->whereIn('user_id', $memberIds)
|
||||
->where('month', $month)
|
||||
->where('year', $year)
|
||||
->selectRaw(SalesVolumePoints::aboPoints().' as points')
|
||||
->value('points');
|
||||
}
|
||||
|
||||
/**
|
||||
* Noch offene Abo-Ausführungen des laufenden Monats: aktive Abos der
|
||||
* Payline-Mitglieder plus — je nach {@see INCLUDE_OWN_ABOS} — die eigenen
|
||||
* Abos/Kundenabos des Betrachters.
|
||||
*
|
||||
* @param int[] $memberIds
|
||||
*/
|
||||
private function upcomingAboQuery(User $user, array $memberIds, Carbon $today): Builder
|
||||
{
|
||||
$aboUserIds = self::INCLUDE_OWN_ABOS
|
||||
? array_values(array_unique(array_merge([(int) $user->id], $memberIds)))
|
||||
: $memberIds;
|
||||
|
||||
return UserAbo::query()
|
||||
->where('active', true)
|
||||
->where('status', 2)
|
||||
->where('next_date', '>', $today->copy()->endOfDay())
|
||||
->where('next_date', '<=', $today->copy()->endOfMonth())
|
||||
->where(function (Builder $query) use ($aboUserIds) {
|
||||
$query->where(fn (Builder $inner) => $inner->where('is_for', 'me')->whereIn('user_id', $aboUserIds))
|
||||
->orWhere(fn (Builder $inner) => $inner->where('is_for', 'ot')->whereIn('member_id', $aboUserIds));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -3,9 +3,14 @@
|
|||
namespace App\Services\Abo;
|
||||
|
||||
use App\Models\UserAbo;
|
||||
use App\Models\UserSalesVolume;
|
||||
use App\Services\AboHelper;
|
||||
use App\Services\Backoffice\BackofficeDashboardService;
|
||||
use App\Services\Backoffice\SalesVolumePoints;
|
||||
use App\Services\HTMLHelper;
|
||||
use App\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
|
|
@ -59,6 +64,10 @@ class TeamAboOverviewService
|
|||
{
|
||||
$teamUserIds = $this->dashboardService->activeTeamUserIds($user->id);
|
||||
|
||||
// 1000-Punkte-Shop: der Berater selbst zählt mit (Kundenwunsch), alle
|
||||
// übrigen Team-Kennzahlen bleiben ohne den Betrachter.
|
||||
$shop1000UserIds = array_merge([(int) $user->id], $teamUserIds);
|
||||
|
||||
if ($teamUserIds === []) {
|
||||
return [
|
||||
'team_total_abos' => 0,
|
||||
|
|
@ -66,7 +75,7 @@ class TeamAboOverviewService
|
|||
'team_partner_abos_new' => 0,
|
||||
'team_customer_abos' => 0,
|
||||
'team_customer_abos_new' => 0,
|
||||
'shop_1000' => 0,
|
||||
'shop_1000' => $this->dashboardService->shop1000Count($shop1000UserIds, $month, $year),
|
||||
'new_partners' => 0,
|
||||
];
|
||||
}
|
||||
|
|
@ -80,7 +89,7 @@ class TeamAboOverviewService
|
|||
'team_partner_abos_new' => $this->newAboCount($teamUserIds, 'user_id', 'me', $month, $year),
|
||||
'team_customer_abos' => $customerAbos,
|
||||
'team_customer_abos_new' => $this->newAboCount($teamUserIds, 'member_id', 'ot', $month, $year),
|
||||
'shop_1000' => $this->dashboardService->shop1000Count($teamUserIds, $month, $year),
|
||||
'shop_1000' => $this->dashboardService->shop1000Count($shop1000UserIds, $month, $year),
|
||||
'new_partners' => $this->dashboardService->newPartnersCount($teamUserIds, $month, $year),
|
||||
];
|
||||
}
|
||||
|
|
@ -117,7 +126,7 @@ class TeamAboOverviewService
|
|||
public function monthlyGrowth(User $user, int $year): array
|
||||
{
|
||||
$teamUserIds = $this->dashboardService->activeTeamUserIds($user->id);
|
||||
$monthNames = \App\Services\HTMLHelper::getTransMonths();
|
||||
$monthNames = HTMLHelper::getTransMonths();
|
||||
|
||||
$ownByMonth = $teamUserIds === [] ? [] : $this->newAbosByMonth($teamUserIds, 'user_id', 'me', $year);
|
||||
$customerByMonth = $teamUserIds === [] ? [] : $this->newAbosByMonth($teamUserIds, 'member_id', 'ot', $year);
|
||||
|
|
@ -191,7 +200,7 @@ class TeamAboOverviewService
|
|||
*/
|
||||
public function memberAboSummary(User $owner, int $memberId): ?array
|
||||
{
|
||||
if (! \App\Services\AboHelper::isUserInTeam($owner->id, $memberId)) {
|
||||
if (! AboHelper::isUserInTeam($owner->id, $memberId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -245,7 +254,7 @@ class TeamAboOverviewService
|
|||
}
|
||||
|
||||
/**
|
||||
* @param \Illuminate\Support\Collection<int, \App\User> $users
|
||||
* @param Collection<int, User> $users
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildLineRow(int $line, Collection $users, int $month, int $year): array
|
||||
|
|
@ -303,6 +312,8 @@ class TeamAboOverviewService
|
|||
];
|
||||
}
|
||||
|
||||
$billedPoints = $this->aboBilledPoints($userIds, $month, $year);
|
||||
|
||||
return [
|
||||
'line' => $line,
|
||||
'label' => __('abo.team_line', ['line' => $line]),
|
||||
|
|
@ -315,16 +326,80 @@ class TeamAboOverviewService
|
|||
'customer_abos' => $customerAbosCount,
|
||||
'customer_abos_new' => $this->newAboCount($userIds, 'member_id', 'ot', $month, $year),
|
||||
'customer_abo_points' => $customerAboPoints,
|
||||
'abo_points_billed' => $billedPoints,
|
||||
'abo_points_expected' => $billedPoints + $this->upcomingAboPointsUntilLastDeliveryDay($userIds, $month, $year),
|
||||
'total_abos' => $ownAbosCount + $customerAbosCount,
|
||||
'members' => $members,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Im Monat bereits abgerechnete Abo-Punkte (Eigen- und Kundenabos) der
|
||||
* übergebenen Berater — gleiche Zählweise wie der Payline-Ist-Wert
|
||||
* ({@see PaylineForecastService::aboActualPoints()}).
|
||||
*
|
||||
* @param int[] $userIds
|
||||
*/
|
||||
private function aboBilledPoints(array $userIds, int $month, int $year): float
|
||||
{
|
||||
if ($userIds === []) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return (float) UserSalesVolume::query()
|
||||
->leftJoin('shopping_orders', 'shopping_orders.id', '=', 'user_sales_volumes.shopping_order_id')
|
||||
->whereIn('user_id', $userIds)
|
||||
->where('month', $month)
|
||||
->where('year', $year)
|
||||
->selectRaw(SalesVolumePoints::aboPoints().' as points')
|
||||
->value('points');
|
||||
}
|
||||
|
||||
/**
|
||||
* Abo-Punkte der noch offenen Ausführungen bis einschließlich zum letzten
|
||||
* Liefertag (25.) des Monats: aktive Abos der Berater mit next_date nach
|
||||
* heute und im angefragten Monat. Für vergangene/zukünftige Monate 0 —
|
||||
* die Prognose ergibt nur für den laufenden Monat Sinn.
|
||||
*
|
||||
* @param int[] $userIds
|
||||
*/
|
||||
private function upcomingAboPointsUntilLastDeliveryDay(array $userIds, int $month, int $year): float
|
||||
{
|
||||
$today = Carbon::today();
|
||||
|
||||
if ($userIds === [] || (int) $today->month !== $month || (int) $today->year !== $year) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$lastDeliveryDay = (int) max(UserAbo::$aboDeliveryDays);
|
||||
$windowEnd = Carbon::create($year, $month, 1)->addDays($lastDeliveryDay - 1)->endOfDay();
|
||||
|
||||
if ($windowEnd->lte($today->copy()->endOfDay())) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return (float) UserAbo::query()
|
||||
->join('user_abo_items', 'user_abo_items.user_abo_id', '=', 'user_abos.id')
|
||||
->join('products', 'products.id', '=', 'user_abo_items.product_id')
|
||||
->whereNull('products.deleted_at')
|
||||
->where('user_abos.active', true)
|
||||
->where('user_abos.status', 2)
|
||||
->where('user_abo_items.comp', 0)
|
||||
->where('user_abos.next_date', '>', $today->copy()->endOfDay())
|
||||
->where('user_abos.next_date', '<=', $windowEnd)
|
||||
->where(function (Builder $query) use ($userIds) {
|
||||
$query->where(fn (Builder $inner) => $inner->where('user_abos.is_for', 'me')->whereIn('user_abos.user_id', $userIds))
|
||||
->orWhere(fn (Builder $inner) => $inner->where('user_abos.is_for', 'ot')->whereIn('user_abos.member_id', $userIds));
|
||||
})
|
||||
->selectRaw('COALESCE(SUM(products.points * user_abo_items.qty), 0) as points')
|
||||
->value('points');
|
||||
}
|
||||
|
||||
/**
|
||||
* Eigene (me) aktive Abos je Berater, indiziert nach user_id (ein Abo pro Berater).
|
||||
*
|
||||
* @param int[] $userIds
|
||||
* @return \Illuminate\Support\Collection<int, \App\Models\UserAbo>
|
||||
* @return Collection<int, UserAbo>
|
||||
*/
|
||||
private function ownAbosByUser(array $userIds): Collection
|
||||
{
|
||||
|
|
@ -341,7 +416,7 @@ class TeamAboOverviewService
|
|||
* Kundenabos (ot) je Berater, gruppiert nach member_id.
|
||||
*
|
||||
* @param int[] $userIds
|
||||
* @return \Illuminate\Support\Collection<int, \Illuminate\Support\Collection<int, \App\Models\UserAbo>>
|
||||
* @return Collection<int, Collection<int, UserAbo>>
|
||||
*/
|
||||
private function customerAbosByMember(array $userIds): Collection
|
||||
{
|
||||
|
|
@ -399,6 +474,8 @@ class TeamAboOverviewService
|
|||
'customer_abos' => 0,
|
||||
'customer_abos_new' => 0,
|
||||
'customer_abo_points' => 0.0,
|
||||
'abo_points_billed' => 0.0,
|
||||
'abo_points_expected' => 0.0,
|
||||
'total_abos' => 0,
|
||||
'members' => [],
|
||||
];
|
||||
|
|
@ -411,7 +488,7 @@ class TeamAboOverviewService
|
|||
*/
|
||||
private function addToTotals(array $totals, array $row): array
|
||||
{
|
||||
foreach (['consultants', 'members_total', 'members_with_own_abo', 'own_abos', 'own_abos_new', 'own_abo_points', 'customer_abos', 'customer_abos_new', 'customer_abo_points', 'total_abos'] as $key) {
|
||||
foreach (['consultants', 'members_total', 'members_with_own_abo', 'own_abos', 'own_abos_new', 'own_abo_points', 'customer_abos', 'customer_abos_new', 'customer_abo_points', 'abo_points_billed', 'abo_points_expected', 'total_abos'] as $key) {
|
||||
$totals[$key] += $row[$key];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,26 @@ class AboHelper
|
|||
*/
|
||||
public const DEFAULT_ONETIME_WINDOW_DAYS = 4;
|
||||
|
||||
/**
|
||||
* Standard-Mindestlaufzeit (bezahlte Lieferungen) bis zur Kündigung, falls
|
||||
* das Setting `abo-min-duration` nicht gepflegt ist.
|
||||
*/
|
||||
public const DEFAULT_ABO_MIN_DURATION = 6;
|
||||
|
||||
/**
|
||||
* Abo-Status, die ein Abo endgültig beenden (4 = gekündigt, 5 = beendet).
|
||||
* Solche Abos blockieren die Anlage eines neuen Abos nicht mehr
|
||||
* (Ein-Abo-Regel gilt nur für laufende Abos).
|
||||
*/
|
||||
public const ENDED_STATUSES = [4, 5];
|
||||
|
||||
/**
|
||||
* Standard-Abstand (Monate) zwischen zwei Pausen, falls das Setting
|
||||
* `abo-pause-interval-months` nicht gepflegt ist (max. 1 Pause pro X Monate,
|
||||
* rollierend ab der letzten Pause).
|
||||
*/
|
||||
public const DEFAULT_PAUSE_INTERVAL_MONTHS = 6;
|
||||
|
||||
public static $txaction_filter_text = [
|
||||
'paid' => 'paymend_paid',
|
||||
'appointed' => 'paymend_open',
|
||||
|
|
@ -48,7 +68,7 @@ class AboHelper
|
|||
{
|
||||
$user = $user ? $user : \Auth::user();
|
||||
|
||||
return UserAbo::where('user_id', $user->id)->where('is_for', 'me')->where('status', '>=', 1)->first() === null ? false : true;
|
||||
return UserAbo::where('user_id', $user->id)->where('is_for', 'me')->where('status', '>=', 1)->whereNotIn('status', self::ENDED_STATUSES)->first() === null ? false : true;
|
||||
}
|
||||
|
||||
public static function memberHasAbo(ShoppingUser $shopping_user)
|
||||
|
|
@ -57,12 +77,12 @@ class AboHelper
|
|||
return false;
|
||||
}
|
||||
|
||||
return UserAbo::where('email', $shopping_user->billing_email)->where('is_for', 'ot')->where('status', '>=', 1)->first() === null ? false : true;
|
||||
return UserAbo::where('email', $shopping_user->billing_email)->where('is_for', 'ot')->where('status', '>=', 1)->whereNotIn('status', self::ENDED_STATUSES)->first() === null ? false : true;
|
||||
}
|
||||
|
||||
public static function hasAboByEmail($email)
|
||||
{
|
||||
return UserAbo::where('email', $email)->where('status', '>=', 1)->first() === null ? false : true;
|
||||
return UserAbo::where('email', $email)->where('status', '>=', 1)->whereNotIn('status', self::ENDED_STATUSES)->first() === null ? false : true;
|
||||
}
|
||||
|
||||
public static function setAboStatus(ShoppingOrder $shopping_order, $status, $paid = false)
|
||||
|
|
@ -97,7 +117,9 @@ class AboHelper
|
|||
|
||||
public static function getAboMinDuration()
|
||||
{
|
||||
return Setting::getContentBySlug('abo-min-duration');
|
||||
$minDuration = Setting::getContentBySlug('abo-min-duration');
|
||||
|
||||
return $minDuration === false || $minDuration === null || $minDuration === '' ? self::DEFAULT_ABO_MIN_DURATION : $minDuration;
|
||||
}
|
||||
|
||||
public static function canCancelAbo(UserAbo $user_abo, $view = 'user')
|
||||
|
|
@ -120,6 +142,59 @@ class AboHelper
|
|||
return ! self::canCancelAbo($user_abo, $view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Konfigurierter Mindestabstand (Monate) zwischen zwei Pausen.
|
||||
*/
|
||||
public static function getPauseIntervalMonths(): int
|
||||
{
|
||||
$months = (int) Setting::getContentBySlug('abo-pause-interval-months');
|
||||
|
||||
return $months > 0 ? $months : self::DEFAULT_PAUSE_INTERVAL_MONTHS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Darf das Abo aktuell um einen Monat pausiert werden?
|
||||
* Voraussetzungen (außer Admin): Abo läuft (aktiv, Status 2), Mindestlaufzeit
|
||||
* erreicht (gleiche Basis wie die Kündigung, Setting `abo-min-duration`) und
|
||||
* die letzte Pause liegt mindestens {@see getPauseIntervalMonths()} Monate
|
||||
* zurück (rollierend). Die Sperrfrist vor der Ausführung
|
||||
* (`abo-lock-days-pause-cancel`) wird separat in der Pause-Aktion geprüft.
|
||||
*/
|
||||
public static function canPauseAbo(UserAbo $user_abo, $view = 'user'): bool
|
||||
{
|
||||
if (! $user_abo->active || (int) $user_abo->status !== 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($view === 'admin') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! self::canCancelAbo($user_abo, $view)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return self::nextPossiblePauseDate($user_abo) === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Frühestes Datum, ab dem wieder pausiert werden darf — oder null, wenn die
|
||||
* Pause bereits jetzt möglich ist (keine oder ausreichend lange zurückliegende
|
||||
* letzte Pause). Dient auch dem UI-Hinweis "wieder möglich ab".
|
||||
*/
|
||||
public static function nextPossiblePauseDate(UserAbo $user_abo): ?Carbon
|
||||
{
|
||||
$lastPauseDate = $user_abo->getRawOriginal('last_pause_date');
|
||||
|
||||
if (! $lastPauseDate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$nextPossible = Carbon::parse($lastPauseDate)->startOfDay()->addMonthsNoOverflow(self::getPauseIntervalMonths());
|
||||
|
||||
return $nextPossible->lte(Carbon::today()) ? null : $nextPossible;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob das Bearbeiten des Abos (Liefertag/Intervall) aktuell durch die
|
||||
* Sperrfrist vor der Ausführung blockiert ist. Admins umgehen die Sperre.
|
||||
|
|
|
|||
|
|
@ -50,7 +50,11 @@ class BackofficeDashboardService
|
|||
->first();
|
||||
|
||||
if ($snapshot) {
|
||||
return $this->withMeta($snapshot->payload, 'snapshot', $snapshot->calculated_at?->format('d.m.Y H:i'));
|
||||
return $this->withMeta(
|
||||
$this->overlayCurrentAboStock($user, $snapshot->payload),
|
||||
'snapshot',
|
||||
$snapshot->calculated_at?->format('d.m.Y H:i')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -89,6 +93,10 @@ class BackofficeDashboardService
|
|||
$totals = $this->addToTotals($totals, $row);
|
||||
}
|
||||
|
||||
// 1000-Punkte-Shop: der VIP selbst zählt mit (Kundenwunsch), obwohl er in
|
||||
// keiner Linie steckt — nur diese Kennzahl bekommt ihn zusätzlich.
|
||||
$totals['shop_1000'] += $this->shop1000Count([(int) $user->id], $month, $year);
|
||||
|
||||
$totals['label'] = 'Summe';
|
||||
|
||||
return [
|
||||
|
|
@ -97,11 +105,91 @@ class BackofficeDashboardService
|
|||
'metric_labels' => $this->metricLabels(),
|
||||
'lines' => $lines,
|
||||
'totals' => $totals,
|
||||
'new_customer_abos' => $this->newCustomerAboRanking($lineBuckets, $month, $year),
|
||||
'non_payline_points' => $this->nonPaylinePointsSum($totals['user_ids'], $month, $year),
|
||||
'new_customer_abos' => $this->newCustomerAboRanking($user, $lineBuckets, $month, $year),
|
||||
'leader_abos' => $this->leaderAboOverview($lineBuckets),
|
||||
'top_customer_abos' => $this->topCustomerAboRanking($user, $lineBuckets, $month, $year),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Überlagert einen Snapshot-Payload mit den tagesaktuellen Abo-Bestandszahlen
|
||||
* (Kundenentscheid F7): Teamabos/Teamkundenabos je Linie + Summe sowie die
|
||||
* Führungskräfte- und Top-10-Listen sind Bestands-Kennzahlen ohne Monatsbezug
|
||||
* und werden live nachgerechnet. Monatsbezogene Werte (Punkte, Umsätze,
|
||||
* Neupartner, "+N neu", neue Kundenabos) bleiben eingefroren.
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function overlayCurrentAboStock(User $user, array $payload): array
|
||||
{
|
||||
$lineBuckets = $this->lineBuckets($user->id);
|
||||
|
||||
$totalPartnerAbos = 0;
|
||||
$totalCustomerAbos = 0;
|
||||
|
||||
$snapshotLines = collect($payload['lines'] ?? [])
|
||||
->map(fn ($row, $key) => (int) ($row['line'] ?? $key))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
foreach ($payload['lines'] ?? [] as $key => $row) {
|
||||
$line = (int) ($row['line'] ?? $key);
|
||||
$userIds = ($lineBuckets[$line] ?? collect())->pluck('id')->map(fn ($id) => (int) $id)->all();
|
||||
|
||||
$partnerAbos = $userIds === [] ? 0 : $this->activeAboQuery()->whereIn('user_id', $userIds)->where('is_for', 'me')->count();
|
||||
$customerAbos = $userIds === [] ? 0 : $this->activeAboQuery()->whereIn('member_id', $userIds)->where('is_for', 'ot')->count();
|
||||
|
||||
$payload['lines'][$key]['team_partner_abos'] = $partnerAbos;
|
||||
$payload['lines'][$key]['team_customer_abos'] = $customerAbos;
|
||||
|
||||
$totalPartnerAbos += $partnerAbos;
|
||||
$totalCustomerAbos += $customerAbos;
|
||||
}
|
||||
|
||||
// Linien, die erst nach dem Snapshot entstanden sind (Team ist heute tiefer),
|
||||
// tragen ebenfalls aktuellen Abo-Bestand: als neue Zeile anhängen, Monatswerte 0.
|
||||
foreach ($lineBuckets as $line => $users) {
|
||||
if (in_array((int) $line, $snapshotLines, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$userIds = $users->pluck('id')->map(fn ($id) => (int) $id)->all();
|
||||
if ($userIds === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$partnerAbos = $this->activeAboQuery()->whereIn('user_id', $userIds)->where('is_for', 'me')->count();
|
||||
$customerAbos = $this->activeAboQuery()->whereIn('member_id', $userIds)->where('is_for', 'ot')->count();
|
||||
|
||||
if ($partnerAbos === 0 && $customerAbos === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$row = $this->emptyLine((int) $line, $userIds);
|
||||
$row['team_partner_abos'] = $partnerAbos;
|
||||
$row['team_customer_abos'] = $customerAbos;
|
||||
$payload['lines'][(int) $line] = $row;
|
||||
|
||||
$totalPartnerAbos += $partnerAbos;
|
||||
$totalCustomerAbos += $customerAbos;
|
||||
}
|
||||
|
||||
$payload['lines'] = collect($payload['lines'] ?? [])->sortBy('line')->all();
|
||||
|
||||
if (isset($payload['totals'])) {
|
||||
$payload['totals']['team_partner_abos'] = $totalPartnerAbos;
|
||||
$payload['totals']['team_customer_abos'] = $totalCustomerAbos;
|
||||
}
|
||||
|
||||
$payload['leader_abos'] = $this->leaderAboOverview($lineBuckets);
|
||||
$payload['top_customer_abos'] = $this->topCustomerAboRanking($user, $lineBuckets, (int) ($payload['month'] ?? now()->month), (int) ($payload['year'] ?? now()->year));
|
||||
$payload['abo_stock_live'] = true;
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overview
|
||||
* @return array<string, mixed>
|
||||
|
|
@ -123,7 +211,7 @@ class BackofficeDashboardService
|
|||
}
|
||||
|
||||
/**
|
||||
* @return array<int, \Illuminate\Support\Collection<int, \App\User>>
|
||||
* @return array<int, Collection<int, User>>
|
||||
*/
|
||||
public function lineBuckets(int $rootUserId): array
|
||||
{
|
||||
|
|
@ -206,7 +294,7 @@ class BackofficeDashboardService
|
|||
}
|
||||
|
||||
/**
|
||||
* @param \Illuminate\Support\Collection<int, \App\User> $users
|
||||
* @param Collection<int, User> $users
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildLineRow(int $line, Collection $users, int $month, int $year): array
|
||||
|
|
@ -286,7 +374,7 @@ class BackofficeDashboardService
|
|||
}
|
||||
|
||||
/**
|
||||
* @param \Illuminate\Support\Collection<int, \App\User> $users
|
||||
* @param Collection<int, User> $users
|
||||
*/
|
||||
private function activeConsultants(Collection $users): int
|
||||
{
|
||||
|
|
@ -296,7 +384,7 @@ class BackofficeDashboardService
|
|||
}
|
||||
|
||||
/**
|
||||
* @param \Illuminate\Support\Collection<int, \App\User> $users
|
||||
* @param Collection<int, User> $users
|
||||
*/
|
||||
private function newPartners(Collection $users, int $month, int $year): int
|
||||
{
|
||||
|
|
@ -376,6 +464,27 @@ class BackofficeDashboardService
|
|||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @param int[] $userIds
|
||||
*/
|
||||
private function nonPaylinePointsSum(array $userIds, int $month, int $year): float
|
||||
{
|
||||
if ($userIds === []) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return (float) UserSalesVolume::query()
|
||||
->whereIn('user_id', $userIds)
|
||||
->where('month', $month)
|
||||
->where('year', $year)
|
||||
->selectRaw(SalesVolumePoints::nonPaylinePoints().' as points')
|
||||
->value('points');
|
||||
}
|
||||
|
||||
/**
|
||||
* Anzahl der Neupartner (aktiver Account, im Monat aktiv geworden).
|
||||
* Konsistent mit der Drilldown-Liste {@see BackofficeDrilldownService::newPartnerRows()}.
|
||||
|
|
@ -404,14 +513,15 @@ class BackofficeDashboardService
|
|||
* Ranking: Wer im Team hat im Monat wie viele neue Kundenabos abgeschlossen?
|
||||
* Sortiert nach Anzahl absteigend (nicht nach Linie), Linie wird je Berater mitgeliefert,
|
||||
* dazu der aktuelle Gesamtbestand an Kundenabos des Beraters.
|
||||
* Der Statistik-Nutzer selbst zählt mit (Kundenwunsch, Linie 0 / "ich").
|
||||
* Zählweise identisch zu team_customer_abos_new {@see newAboCount()}.
|
||||
*
|
||||
* @param array<int, \Illuminate\Support\Collection<int, \App\User>> $lineBuckets
|
||||
* @return array<int, array{user_id: int, name: string, career_level: string, line: int, abo_count: int, total_abo_count: int}>
|
||||
* @param array<int, Collection<int, User>> $lineBuckets
|
||||
* @return array<int, array{user_id: int, name: string, career_level: string, line: int, is_self: bool, abo_count: int, total_abo_count: int}>
|
||||
*/
|
||||
private function newCustomerAboRanking(array $lineBuckets, int $month, int $year): array
|
||||
private function newCustomerAboRanking(User $rootUser, array $lineBuckets, int $month, int $year): array
|
||||
{
|
||||
$usersByLine = $this->usersWithLine($lineBuckets);
|
||||
$usersByLine = $this->usersWithLine($lineBuckets, $rootUser);
|
||||
|
||||
if ($usersByLine === []) {
|
||||
return [];
|
||||
|
|
@ -437,6 +547,7 @@ class BackofficeDashboardService
|
|||
'name' => $this->userName($entry['user']),
|
||||
'career_level' => $this->careerLevel($entry['user']),
|
||||
'line' => $entry['line'],
|
||||
'is_self' => $entry['line'] === 0,
|
||||
'abo_count' => (int) $counts->get($userId),
|
||||
'total_abo_count' => (int) $totalCounts->get($userId, 0),
|
||||
])
|
||||
|
|
@ -447,10 +558,21 @@ class BackofficeDashboardService
|
|||
|
||||
/**
|
||||
* Abos der Führungskräfte (ab Stufe Vertriebsleiter) im Team: Gesamtzahlen plus
|
||||
* Liste aller Führungskräfte inklusive Linie, eigener Abos und Kundenabos.
|
||||
* Liste aller Führungskräfte inklusive Linie, eigener Abos, Kundenabos sowie
|
||||
* Team-Abos und Team-Kundenabos ihres Teilbaums und der Gesamtsumme aller
|
||||
* vier Kennzahlen (Kundenwunsch B7).
|
||||
*
|
||||
* @param array<int, \Illuminate\Support\Collection<int, \App\User>> $lineBuckets
|
||||
* @return array{level_name: string, total: int, total_own: int, total_customer: int, rows: array<int, array{user_id: int, name: string, career_level: string, line: int, own_abo_count: int, customer_abo_count: int}>}
|
||||
* Zwei Team-Sichten je Führungskraft (Kundenentscheid 2026-07-24, revidiert F9):
|
||||
* - team_abo_count / team_customer_abo_count ("direkt"): Teilbaum wird an der
|
||||
* nächsten Führungskraft abgeschnitten — überlappungsfrei, die Kopf-Summen
|
||||
* (total_*) bleiben dadurch echte Gesamtwerte ohne Doppelzählung.
|
||||
* - team_abo_count_total / team_customer_abo_count_total ("gesamt"): komplette
|
||||
* Downline der Führungskraft; verschachtelte Führungskräfte zählen hier
|
||||
* mehrfach, deshalb werden diese Spalten nicht aufsummiert.
|
||||
* total_abo_count_full = eigene + Kundenabos + Team gesamt (Sortierung).
|
||||
*
|
||||
* @param array<int, Collection<int, User>> $lineBuckets
|
||||
* @return array{level_name: string, total: int, total_own: int, total_customer: int, total_team: int, total_team_customer: int, rows: array<int, array{user_id: int, name: string, career_level: string, line: int, own_abo_count: int, customer_abo_count: int, team_abo_count: int, team_customer_abo_count: int, team_abo_count_total: int, team_customer_abo_count_total: int, total_abo_count: int, total_abo_count_full: int}>}
|
||||
*/
|
||||
private function leaderAboOverview(array $lineBuckets): array
|
||||
{
|
||||
|
|
@ -458,54 +580,227 @@ class BackofficeDashboardService
|
|||
->where('name', self::LEADER_LEVEL_NAME)
|
||||
->value('pos');
|
||||
|
||||
$leadersByLine = $leaderMinPos === null ? [] : collect($this->usersWithLine($lineBuckets))
|
||||
->filter(fn (array $entry) => ($entry['user']->user_level?->pos ?? 0) >= $leaderMinPos)
|
||||
$usersWithLine = $this->usersWithLine($lineBuckets);
|
||||
$isLeader = fn (User $user): bool => $leaderMinPos !== null && ($user->user_level?->pos ?? 0) >= $leaderMinPos;
|
||||
|
||||
$leadersByLine = $leaderMinPos === null ? [] : collect($usersWithLine)
|
||||
->filter(fn (array $entry) => $isLeader($entry['user']))
|
||||
->all();
|
||||
|
||||
if ($leadersByLine === []) {
|
||||
return ['level_name' => self::LEADER_LEVEL_NAME, 'total' => 0, 'total_own' => 0, 'total_customer' => 0, 'rows' => []];
|
||||
return ['level_name' => self::LEADER_LEVEL_NAME, 'total' => 0, 'total_own' => 0, 'total_customer' => 0, 'total_team' => 0, 'total_team_customer' => 0, 'rows' => []];
|
||||
}
|
||||
|
||||
$leaderIds = array_keys($leadersByLine);
|
||||
$teamIdsByLeader = $this->teamIdsPerLeader($usersWithLine, $leaderIds, $isLeader);
|
||||
$fullTeamIdsByLeader = $this->teamIdsPerLeader($usersWithLine, $leaderIds, fn (User $user): bool => false);
|
||||
$allCountIds = array_values(array_unique(array_merge($leaderIds, ...array_values($fullTeamIdsByLeader))));
|
||||
|
||||
$ownCounts = $this->activeAboQuery()
|
||||
->whereIn('user_id', $leaderIds)
|
||||
->whereIn('user_id', $allCountIds)
|
||||
->where('is_for', 'me')
|
||||
->selectRaw('user_id, COUNT(*) as abo_count')
|
||||
->groupBy('user_id')
|
||||
->pluck('abo_count', 'user_id');
|
||||
|
||||
$customerCounts = $this->customerAboCounts($leaderIds);
|
||||
$customerCounts = $this->customerAboCounts($allCountIds);
|
||||
|
||||
$rows = collect($leadersByLine)
|
||||
->map(function (array $entry, int $userId) use ($ownCounts, $customerCounts, $teamIdsByLeader, $fullTeamIdsByLeader) {
|
||||
$teamIds = $teamIdsByLeader[$userId] ?? [];
|
||||
$fullTeamIds = $fullTeamIdsByLeader[$userId] ?? [];
|
||||
$own = (int) $ownCounts->get($userId, 0);
|
||||
$customer = (int) $customerCounts->get($userId, 0);
|
||||
$team = (int) collect($teamIds)->sum(fn (int $teamUserId) => (int) $ownCounts->get($teamUserId, 0));
|
||||
$teamCustomer = (int) collect($teamIds)->sum(fn (int $teamUserId) => (int) $customerCounts->get($teamUserId, 0));
|
||||
$teamTotal = (int) collect($fullTeamIds)->sum(fn (int $teamUserId) => (int) $ownCounts->get($teamUserId, 0));
|
||||
$teamCustomerTotal = (int) collect($fullTeamIds)->sum(fn (int $teamUserId) => (int) $customerCounts->get($teamUserId, 0));
|
||||
|
||||
return [
|
||||
'user_id' => $userId,
|
||||
'name' => $this->userName($entry['user']),
|
||||
'career_level' => $this->careerLevel($entry['user']),
|
||||
'line' => $entry['line'],
|
||||
'own_abo_count' => $own,
|
||||
'customer_abo_count' => $customer,
|
||||
'team_abo_count' => $team,
|
||||
'team_customer_abo_count' => $teamCustomer,
|
||||
'team_abo_count_total' => $teamTotal,
|
||||
'team_customer_abo_count_total' => $teamCustomerTotal,
|
||||
'total_abo_count' => $own + $customer + $team + $teamCustomer,
|
||||
'total_abo_count_full' => $own + $customer + $teamTotal + $teamCustomerTotal,
|
||||
];
|
||||
})
|
||||
->sortByDesc('total_abo_count_full')
|
||||
->values();
|
||||
|
||||
$totalOwn = (int) $rows->sum('own_abo_count');
|
||||
$totalCustomer = (int) $rows->sum('customer_abo_count');
|
||||
$totalTeam = (int) $rows->sum('team_abo_count');
|
||||
$totalTeamCustomer = (int) $rows->sum('team_customer_abo_count');
|
||||
|
||||
return [
|
||||
'level_name' => self::LEADER_LEVEL_NAME,
|
||||
'total' => $totalOwn + $totalCustomer + $totalTeam + $totalTeamCustomer,
|
||||
'total_own' => $totalOwn,
|
||||
'total_customer' => $totalCustomer,
|
||||
'total_team' => $totalTeam,
|
||||
'total_team_customer' => $totalTeamCustomer,
|
||||
'rows' => $rows->all(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Teilbaum-User-IDs je Führungskraft aus der gemeinsamen Sponsor-Map
|
||||
* (ein Durchlauf über die bereits geladene Downline, keine Abfragen pro
|
||||
* Führungskraft). Der Abstieg stoppt an jedem Kind, für das $isLeader true
|
||||
* liefert (dieses und sein Team werden übersprungen); mit einem immer-false
|
||||
* Callable ergibt sich die komplette Downline.
|
||||
*
|
||||
* @param array<int, array{user: User, line: int}> $usersWithLine
|
||||
* @param int[] $leaderIds
|
||||
* @return array<int, int[]>
|
||||
*/
|
||||
private function teamIdsPerLeader(array $usersWithLine, array $leaderIds, callable $isLeader): array
|
||||
{
|
||||
$childrenBySponsor = [];
|
||||
|
||||
foreach ($usersWithLine as $entry) {
|
||||
$childrenBySponsor[(int) $entry['user']->m_sponsor][] = $entry['user'];
|
||||
}
|
||||
|
||||
$teamIdsByLeader = [];
|
||||
|
||||
foreach ($leaderIds as $leaderId) {
|
||||
$teamIds = [];
|
||||
$queue = $childrenBySponsor[$leaderId] ?? [];
|
||||
|
||||
while ($queue !== []) {
|
||||
$child = array_shift($queue);
|
||||
|
||||
if ($isLeader($child)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$teamIds[] = (int) $child->id;
|
||||
|
||||
foreach ($childrenBySponsor[(int) $child->id] ?? [] as $grandChild) {
|
||||
$queue[] = $grandChild;
|
||||
}
|
||||
}
|
||||
|
||||
$teamIdsByLeader[$leaderId] = $teamIds;
|
||||
}
|
||||
|
||||
return $teamIdsByLeader;
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-10-Ranglisten der Kundenabos im eigenen Team (Kundenwunsch B5/B6,
|
||||
* "unabhängig vom Incentive" = aktueller Gesamtbestand aktiver Kundenabos):
|
||||
*
|
||||
* - by_count: nach Anzahl aktiver Kundenabos je Berater
|
||||
* - by_points: nach Umsatz mit Ist (im gewählten Monat abgerechnete
|
||||
* Kundenabo-Punkte aus user_sales_volumes, Hauptwert und Sortierung —
|
||||
* Kundenentscheid 2026-07-24) und Soll (Abo-Punkte-Bestand, Produktpunkte
|
||||
* × Menge der laufenden Kundenabos) als Zweitwert
|
||||
*
|
||||
* Der Statistik-Nutzer selbst zählt in beiden Listen mit (Linie 0 / "ich").
|
||||
*
|
||||
* @param array<int, Collection<int, User>> $lineBuckets
|
||||
* @return array{by_count: array<int, array<string, mixed>>, by_points: array<int, array<string, mixed>>}
|
||||
*/
|
||||
private function topCustomerAboRanking(User $rootUser, array $lineBuckets, int $month, int $year): array
|
||||
{
|
||||
$usersWithLine = $this->usersWithLine($lineBuckets, $rootUser);
|
||||
|
||||
if ($usersWithLine === []) {
|
||||
return ['by_count' => [], 'by_points' => []];
|
||||
}
|
||||
|
||||
$memberIds = array_keys($usersWithLine);
|
||||
$aboCounts = $this->customerAboCounts($memberIds);
|
||||
$targetPoints = $this->customerAboTargetPoints($memberIds);
|
||||
$actualPoints = $this->customerAboActualPoints($memberIds, $month, $year);
|
||||
|
||||
$rows = collect($usersWithLine)
|
||||
->map(fn (array $entry, int $userId) => [
|
||||
'user_id' => $userId,
|
||||
'name' => $this->userName($entry['user']),
|
||||
'career_level' => $this->careerLevel($entry['user']),
|
||||
'line' => $entry['line'],
|
||||
'own_abo_count' => (int) $ownCounts->get($userId, 0),
|
||||
'customer_abo_count' => (int) $customerCounts->get($userId, 0),
|
||||
])
|
||||
->sortByDesc(fn (array $row) => $row['own_abo_count'] + $row['customer_abo_count'])
|
||||
->values();
|
||||
|
||||
$totalOwn = (int) $rows->sum('own_abo_count');
|
||||
$totalCustomer = (int) $rows->sum('customer_abo_count');
|
||||
'is_self' => $entry['line'] === 0,
|
||||
'abo_count' => (int) $aboCounts->get($userId, 0),
|
||||
'points_target' => (float) $targetPoints->get($userId, 0.0),
|
||||
'points_actual' => (float) $actualPoints->get($userId, 0.0),
|
||||
]);
|
||||
|
||||
return [
|
||||
'level_name' => self::LEADER_LEVEL_NAME,
|
||||
'total' => $totalOwn + $totalCustomer,
|
||||
'total_own' => $totalOwn,
|
||||
'total_customer' => $totalCustomer,
|
||||
'rows' => $rows->all(),
|
||||
'by_count' => $rows
|
||||
->filter(fn (array $row) => $row['abo_count'] > 0)
|
||||
->sortByDesc('abo_count')
|
||||
->take(10)
|
||||
->values()
|
||||
->all(),
|
||||
'by_points' => $rows
|
||||
->filter(fn (array $row) => $row['points_target'] > 0 || $row['points_actual'] > 0)
|
||||
->sortByDesc('points_actual')
|
||||
->take(10)
|
||||
->values()
|
||||
->all(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Soll-Punkte je Berater: Abo-Punkte-Bestand der aktiven Kundenabos
|
||||
* (Produktpunkte × Menge, nur Hauptartikel comp = 0 — gleiche Logik wie
|
||||
* {@see UserAbo::getTotalPoints()}).
|
||||
*
|
||||
* @param int[] $memberIds
|
||||
* @return Collection<int, float>
|
||||
*/
|
||||
private function customerAboTargetPoints(array $memberIds): Collection
|
||||
{
|
||||
return UserAbo::query()
|
||||
->join('user_abo_items', 'user_abo_items.user_abo_id', '=', 'user_abos.id')
|
||||
->join('products', 'products.id', '=', 'user_abo_items.product_id')
|
||||
->whereNull('products.deleted_at')
|
||||
->where('user_abos.active', true)
|
||||
->whereNotIn('user_abos.status', [4, 5, 6])
|
||||
->whereIn('user_abos.member_id', $memberIds)
|
||||
->where('user_abos.is_for', 'ot')
|
||||
->where('user_abo_items.comp', 0)
|
||||
->selectRaw('user_abos.member_id, COALESCE(SUM(products.points * user_abo_items.qty), 0) as points')
|
||||
->groupBy('user_abos.member_id')
|
||||
->pluck('points', 'member_id')
|
||||
->map(fn ($points) => (float) $points);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ist-Punkte je Berater: im Monat tatsächlich abgerechnete Kundenabo-Punkte.
|
||||
*
|
||||
* @param int[] $memberIds
|
||||
* @return Collection<int, float>
|
||||
*/
|
||||
private function customerAboActualPoints(array $memberIds, int $month, int $year): Collection
|
||||
{
|
||||
return UserSalesVolume::query()
|
||||
->leftJoin('shopping_orders', 'shopping_orders.id', '=', 'user_sales_volumes.shopping_order_id')
|
||||
->whereIn('user_id', $memberIds)
|
||||
->where('month', $month)
|
||||
->where('year', $year)
|
||||
->select('user_sales_volumes.user_id')
|
||||
->selectRaw(SalesVolumePoints::customerAboPoints().' as points')
|
||||
->groupBy('user_sales_volumes.user_id')
|
||||
->pluck('points', 'user_id')
|
||||
->map(fn ($points) => (float) $points);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktueller Bestand aktiver Kundenabos je Berater (member_id).
|
||||
*
|
||||
* @param int[] $memberIds
|
||||
* @return \Illuminate\Support\Collection<int, int>
|
||||
* @return Collection<int, int>
|
||||
*/
|
||||
private function customerAboCounts(array $memberIds): Collection
|
||||
{
|
||||
|
|
@ -522,13 +817,20 @@ class BackofficeDashboardService
|
|||
}
|
||||
|
||||
/**
|
||||
* @param array<int, \Illuminate\Support\Collection<int, \App\User>> $lineBuckets
|
||||
* @return array<int, array{user: \App\User, line: int}>
|
||||
* Downline-User mit ihrer Linie; optional inklusive des Statistik-Nutzers
|
||||
* selbst als Linie 0 (er steckt in keinem lineBucket, {@see lineBuckets()}).
|
||||
*
|
||||
* @param array<int, Collection<int, User>> $lineBuckets
|
||||
* @return array<int, array{user: User, line: int}>
|
||||
*/
|
||||
private function usersWithLine(array $lineBuckets): array
|
||||
private function usersWithLine(array $lineBuckets, ?User $rootUser = null): array
|
||||
{
|
||||
$usersByLine = [];
|
||||
|
||||
if ($rootUser !== null) {
|
||||
$usersByLine[(int) $rootUser->id] = ['user' => $rootUser, 'line' => 0];
|
||||
}
|
||||
|
||||
foreach ($lineBuckets as $line => $users) {
|
||||
foreach ($users as $user) {
|
||||
$usersByLine[(int) $user->id] = ['user' => $user, 'line' => $line];
|
||||
|
|
|
|||
|
|
@ -7,15 +7,16 @@ use App\Models\UserSalesVolume;
|
|||
use App\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class BackofficeDrilldownService
|
||||
{
|
||||
public function __construct(private BackofficeDashboardService $dashboardService) {}
|
||||
|
||||
/**
|
||||
* @return array{metric: string, metric_label: string, line: int, line_label: string, month: int, year: int, rows: array<int, array<string, mixed>>, summary: array<string, mixed>}
|
||||
* @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): array
|
||||
public function details(User $viewer, int $line, string $metric, int $month, int $year, bool $onlyNew = false): array
|
||||
{
|
||||
$metricLabels = $this->dashboardService->metricLabels();
|
||||
|
||||
|
|
@ -23,19 +24,31 @@ class BackofficeDrilldownService
|
|||
abort(404);
|
||||
}
|
||||
|
||||
$onlyNew = $onlyNew && in_array($metric, ['team_partner_abos', 'team_customer_abos'], true);
|
||||
|
||||
$userIds = $this->dashboardService->lineUserIds($viewer->id, $line);
|
||||
|
||||
if ($metric === 'shop_1000' && $line === 0) {
|
||||
$userIds[] = (int) $viewer->id;
|
||||
}
|
||||
|
||||
$rows = match ($metric) {
|
||||
'consultants' => $this->consultantRows($userIds),
|
||||
'new_partners' => $this->newPartnerRows($userIds, $month, $year),
|
||||
'team_partner_abos' => $this->partnerAboRows($userIds, $month, $year),
|
||||
'team_customer_abos' => $this->customerAboRows($userIds, $month, $year),
|
||||
'team_partner_abos' => $this->partnerAboRows($userIds, $month, $year, $onlyNew),
|
||||
'team_customer_abos' => $this->customerAboRows($userIds, $month, $year, $onlyNew),
|
||||
'own_points', 'external_points', 'customer_abo_points', 'customer_single_order_points', 'customer_other_points', 'total_points', 'shop_1000' => $this->pointsRows($userIds, $month, $year, $metric),
|
||||
default => [],
|
||||
};
|
||||
|
||||
if ($metric === 'shop_1000') {
|
||||
$rows = $this->markViewerRow($rows, (int) $viewer->id);
|
||||
}
|
||||
|
||||
return [
|
||||
'metric' => $metric,
|
||||
'metric_label' => $metricLabels[$metric],
|
||||
'metric_label' => $metricLabels[$metric].($onlyNew ? ' – nur neue' : ''),
|
||||
'only_new' => $onlyNew,
|
||||
'line' => $line,
|
||||
'line_label' => $line > 0 ? 'Linie '.$line : 'Alle Linien',
|
||||
'month' => $month,
|
||||
|
|
@ -46,8 +59,26 @@ class BackofficeDrilldownService
|
|||
}
|
||||
|
||||
/**
|
||||
* Kennzeichnet die eigene Zeile des Betrachters (der VIP zählt beim
|
||||
* 1000-Punkte-Shop selbst mit und soll in der Liste erkennbar sein).
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
* @return array{count: int, abo_count: int, points: float, own_points: float, external_points: float, customer_abo_points: float, customer_single_order_points: float, customer_other_points: float, total_points: float, deliveries: int}
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function markViewerRow(array $rows, int $viewerId): array
|
||||
{
|
||||
return array_map(function (array $row) use ($viewerId): array {
|
||||
if ((int) ($row['user_id'] ?? 0) === $viewerId) {
|
||||
$row['name'] .= ' (ich)';
|
||||
}
|
||||
|
||||
return $row;
|
||||
}, $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
* @return array{count: int, abo_count: int, points: float, customer_points: float, own_points: float, external_points: float, customer_abo_points: float, customer_single_order_points: float, customer_other_points: float, total_points: float, deliveries: int}
|
||||
*/
|
||||
private function summary(array $rows): array
|
||||
{
|
||||
|
|
@ -55,6 +86,7 @@ class BackofficeDrilldownService
|
|||
'count' => count($rows),
|
||||
'abo_count' => (int) collect($rows)->sum('abo_count'),
|
||||
'points' => (float) collect($rows)->sum('points'),
|
||||
'customer_points' => (float) collect($rows)->sum('customer_points'),
|
||||
'own_points' => (float) collect($rows)->sum('own_points'),
|
||||
'external_points' => (float) collect($rows)->sum('external_points'),
|
||||
'customer_abo_points' => (float) collect($rows)->sum('customer_abo_points'),
|
||||
|
|
@ -102,7 +134,7 @@ class BackofficeDrilldownService
|
|||
$startDate = Carbon::create($year, $month, 1)->startOfMonth();
|
||||
$endDate = Carbon::create($year, $month, 1)->endOfMonth();
|
||||
|
||||
return User::query()
|
||||
$partners = User::query()
|
||||
->with(['account', 'user_level'])
|
||||
->whereIn('id', $userIds)
|
||||
->whereNotNull('m_level')
|
||||
|
|
@ -111,12 +143,25 @@ class BackofficeDrilldownService
|
|||
->orderBy('active_date')
|
||||
->get()
|
||||
->filter(fn (User $user) => Carbon::parse($user->payment_account)->isFuture())
|
||||
->values();
|
||||
|
||||
$sponsors = User::query()
|
||||
->with('account')
|
||||
->whereIn('id', $partners->pluck('m_sponsor')->filter()->unique())
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$customerPoints = $this->externalPointsByUser($partners->pluck('id')->map(fn ($id) => (int) $id)->all(), $month, $year);
|
||||
|
||||
return $partners
|
||||
->map(fn (User $user) => [
|
||||
'type' => 'user',
|
||||
'user_id' => $user->id,
|
||||
'name' => $this->userName($user),
|
||||
'email' => $user->email,
|
||||
'career_level' => $this->careerLevel($user),
|
||||
'sponsor' => $user->m_sponsor && $sponsors->has((int) $user->m_sponsor) ? $this->userName($sponsors->get((int) $user->m_sponsor)) : '-',
|
||||
'customer_points' => (float) $customerPoints->get((int) $user->id, 0.0),
|
||||
'active_date' => $this->formatDate($user->active_date),
|
||||
'payment_account' => $this->formatDate($user->payment_account),
|
||||
])
|
||||
|
|
@ -124,16 +169,41 @@ class BackofficeDrilldownService
|
|||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Externe Kundenpunkte (Shop + Abo) je User im gewählten Monat — tagesaktuell
|
||||
* aus user_sales_volumes summiert (gleiche Status-Logik wie die Übersicht).
|
||||
*
|
||||
* @param int[] $userIds
|
||||
* @return Collection<int, float>
|
||||
*/
|
||||
private function externalPointsByUser(array $userIds, int $month, int $year): Collection
|
||||
{
|
||||
if ($userIds === []) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return UserSalesVolume::query()
|
||||
->whereIn('user_id', $userIds)
|
||||
->where('month', $month)
|
||||
->where('year', $year)
|
||||
->select('user_id')
|
||||
->selectRaw(SalesVolumePoints::externalPoints().' as external_points')
|
||||
->groupBy('user_id')
|
||||
->pluck('external_points', 'user_id')
|
||||
->map(fn ($points) => (float) $points);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[] $userIds
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function partnerAboRows(array $userIds, int $month, int $year): array
|
||||
private function partnerAboRows(array $userIds, int $month, int $year, bool $onlyNew = false): array
|
||||
{
|
||||
return $this->activeAboQuery()
|
||||
->with(['user.account', 'user.user_level', 'user_abo_items.product', 'user_abo_orders.shopping_order.shopping_payments.payment_transactions'])
|
||||
->whereIn('user_id', $userIds)
|
||||
->where('is_for', 'me')
|
||||
->when($onlyNew, fn (Builder $query) => $this->whereNewInMonth($query, $month, $year))
|
||||
->orderBy('next_date')
|
||||
->get()
|
||||
->map(fn (UserAbo $abo) => [
|
||||
|
|
@ -165,12 +235,13 @@ class BackofficeDrilldownService
|
|||
* @param int[] $userIds
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function customerAboRows(array $userIds, int $month, int $year): array
|
||||
private function customerAboRows(array $userIds, int $month, int $year, bool $onlyNew = false): array
|
||||
{
|
||||
return $this->activeAboQuery()
|
||||
->with(['member.account', 'member.user_level', 'user_abo_items.product'])
|
||||
->whereIn('member_id', $userIds)
|
||||
->where('is_for', 'ot')
|
||||
->when($onlyNew, fn (Builder $query) => $this->whereNewInMonth($query, $month, $year))
|
||||
->orderBy('member_id')
|
||||
->orderBy('next_date')
|
||||
->get()
|
||||
|
|
@ -279,6 +350,17 @@ class BackofficeDrilldownService
|
|||
->whereNotIn('status', [4, 5, 6]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nur Abos, die im gewählten Monat gestartet sind (gleiche Zählweise wie
|
||||
* die "+N neu"-Badges, {@see BackofficeDashboardService::newAboCount()}).
|
||||
*/
|
||||
private function whereNewInMonth(Builder $query, int $month, int $year): Builder
|
||||
{
|
||||
$startDate = Carbon::create($year, $month, 1)->startOfMonth();
|
||||
|
||||
return $query->whereBetween('start_date', [$startDate, $startDate->copy()->endOfMonth()]);
|
||||
}
|
||||
|
||||
private function userName(User $user): string
|
||||
{
|
||||
$name = trim(($user->account?->first_name ?? '').' '.($user->account?->last_name ?? ''));
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
namespace App\Services\Backoffice;
|
||||
|
||||
use App\Services\BusinessPlan\SalesPointsVolume;
|
||||
|
||||
/**
|
||||
* Liefert SQL-Aggregat-Ausdrücke für die monatliche Punkte-/Umsatzauswertung
|
||||
* der Backoffice-Statistik.
|
||||
|
|
@ -11,7 +13,7 @@ namespace App\Services\Backoffice;
|
|||
* kumulative Monats-Laufstand zum Zeitpunkt der Bestellung – KEINE Einzelbeträge.
|
||||
* Eine SUM() über diese Spalten zählt mehrfach. Korrekt wird je Zeile die
|
||||
* `points`/`total_net`-Spalte summiert und nach `status` gefiltert – analog zu
|
||||
* {@see \App\Services\BusinessPlan\SalesPointsVolume::reCalculateSalesPointsVolume()}.
|
||||
* {@see SalesPointsVolume::reCalculateSalesPointsVolume()}.
|
||||
*
|
||||
* Status-Zuordnung:
|
||||
* - Eigenpunkte (KP): status 1, 4, 5, 6
|
||||
|
|
@ -59,6 +61,26 @@ class SalesVolumePoints
|
|||
return self::ownPoints().' + '.self::externalPoints();
|
||||
}
|
||||
|
||||
/**
|
||||
* Punkte aus Abo-Bestellungen (eigene wie Kundenabos) — alle zählbaren Status.
|
||||
* Benötigt den Join auf shopping_orders.
|
||||
*/
|
||||
public static function aboPoints(): string
|
||||
{
|
||||
return self::sumWhere(self::TURNOVER_PREDICATE.' AND shopping_orders.is_abo = 1', 'user_sales_volumes.points');
|
||||
}
|
||||
|
||||
/**
|
||||
* Punkte, die nur als Karrierepunkte (KP) des Beraters zählen und NICHT als
|
||||
* Teampunkte in die Payline fließen (status_points = 2, z. B.
|
||||
* Neupartner-Einschreibungspunkte, manuelle Gutschriften) — vgl.
|
||||
* {@see SalesPointsVolume::add_KP_TP_Points()}.
|
||||
*/
|
||||
public static function nonPaylinePoints(): string
|
||||
{
|
||||
return self::sumWhere(self::OWN_PREDICATE.' AND user_sales_volumes.status_points = 2', 'user_sales_volumes.points');
|
||||
}
|
||||
|
||||
public static function turnoverNet(): string
|
||||
{
|
||||
return self::sumWhere(self::TURNOVER_PREDICATE, 'user_sales_volumes.total_net');
|
||||
|
|
|
|||
145
app/Services/BusinessPlan/LiveCommissionService.php
Normal file
145
app/Services/BusinessPlan/LiveCommissionService.php
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\BusinessPlan;
|
||||
|
||||
use App\Models\UserBusiness;
|
||||
use App\User;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* Gemeinsamer Live-Lauf für die "Stand heute"-Kennzahlen (Konzept B1/B8/B10):
|
||||
* EIN TreeCalcBotOptimized-Lauf pro User/Monat liefert Payline-Punkte,
|
||||
* Provisions-Bestandteile und die Payline-Tiefe, 6 h gecacht (F5).
|
||||
*
|
||||
* - Liegt für den Monat bereits ein UserBusiness-Datensatz vor (Monats-Batch
|
||||
* `business:store-optimized` gelaufen), gelten dessen Werte als final.
|
||||
* - Sonst rechnet TreeCalcBotOptimized live — dieselbe Engine wie der
|
||||
* Monats-Batch (inkl. GrowthBonusCalculator und korrigierter Level-Auswahl),
|
||||
* damit Live- und Finalwerte nicht systematisch auseinanderlaufen.
|
||||
* - Fehlgeschlagene Läufe werden NICHT gecacht, damit ein transienter Fehler
|
||||
* nicht 6 h lang Nullwerte anzeigt (`failed` markiert diesen Fall).
|
||||
*/
|
||||
class LiveCommissionService
|
||||
{
|
||||
private const CACHE_HOURS = 6;
|
||||
|
||||
/**
|
||||
* @return array{payline_points: float, paylines: int, pp: float, shop: float, growth: float, is_live: bool, failed: bool}
|
||||
*/
|
||||
public function snapshot(User $user, int $month, int $year): array
|
||||
{
|
||||
$stored = UserBusiness::where('user_id', $user->id)
|
||||
->where('month', $month)
|
||||
->where('year', $year)
|
||||
->first();
|
||||
|
||||
if ($stored) {
|
||||
return $this->buildSnapshot(
|
||||
(float) ($stored->payline_points ?? 0),
|
||||
$this->paylinesFromQualLevel($stored->qual_user_level),
|
||||
(float) ($stored->commission_pp_total ?? 0),
|
||||
(float) ($stored->commission_shop_sales ?? 0),
|
||||
(float) ($stored->commission_growth_total ?? 0),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
$cacheKey = "live_business_{$user->id}_{$month}_{$year}";
|
||||
$cached = Cache::get($cacheKey);
|
||||
if (is_array($cached)) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$snapshot = $this->calculateLive($user, $month, $year);
|
||||
if ($snapshot !== null) {
|
||||
Cache::put($cacheKey, $snapshot, now()->addHours(self::CACHE_HOURS));
|
||||
|
||||
return $snapshot;
|
||||
}
|
||||
|
||||
return $this->buildSnapshot(0.0, 0, 0.0, 0.0, 0.0, true, failed: true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provision "Stand heute" (B1) — alle Bestandteile plus Summe.
|
||||
*
|
||||
* @return array{total: float, pp: float, shop: float, growth: float, is_live: bool}
|
||||
*/
|
||||
public function commission(User $user, int $month, int $year): array
|
||||
{
|
||||
$snapshot = $this->snapshot($user, $month, $year);
|
||||
|
||||
return [
|
||||
'total' => round($snapshot['pp'] + $snapshot['shop'] + $snapshot['growth'], 2),
|
||||
'pp' => $snapshot['pp'],
|
||||
'shop' => $snapshot['shop'],
|
||||
'growth' => $snapshot['growth'],
|
||||
'is_live' => $snapshot['is_live'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{payline_points: float, paylines: int, pp: float, shop: float, growth: float, is_live: bool, failed: bool}|null
|
||||
*/
|
||||
private function calculateLive(User $user, int $month, int $year): ?array
|
||||
{
|
||||
try {
|
||||
$treeCalcBot = new TreeCalcBotOptimized($month, $year, 'member');
|
||||
$treeCalcBot->initBusinesslUserDetail($user);
|
||||
$businessUser = $treeCalcBot->business_user;
|
||||
|
||||
if (! $businessUser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Kein `?? 0` auf den Magic-Properties: die Items haben kein __isset,
|
||||
// isset() wäre dort immer false und das Ergebnis damit immer 0.
|
||||
return $this->buildSnapshot(
|
||||
(float) $businessUser->payline_points,
|
||||
$this->paylinesFromQualLevel($businessUser->qual_user_level),
|
||||
(float) $businessUser->commission_pp_total,
|
||||
(float) $businessUser->commission_shop_sales,
|
||||
(float) $businessUser->commission_growth_total,
|
||||
true
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
\Log::warning("LiveCommissionService: Live-Berechnung fehlgeschlagen für User {$user->id}: ".$e->getMessage());
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Payline-Tiefe (Anzahl bezahlter Ebenen) aus dem Qualifikationslevel.
|
||||
*
|
||||
* @param array<string, mixed>|object|null $qualUserLevel
|
||||
*/
|
||||
private function paylinesFromQualLevel(mixed $qualUserLevel): int
|
||||
{
|
||||
if (is_array($qualUserLevel)) {
|
||||
return (int) ($qualUserLevel['paylines'] ?? 0);
|
||||
}
|
||||
|
||||
if (is_object($qualUserLevel)) {
|
||||
return (int) ($qualUserLevel->paylines ?? 0);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{payline_points: float, paylines: int, pp: float, shop: float, growth: float, is_live: bool, failed: bool}
|
||||
*/
|
||||
private function buildSnapshot(float $paylinePoints, int $paylines, float $pp, float $shop, float $growth, bool $isLive, bool $failed = false): array
|
||||
{
|
||||
return [
|
||||
'payline_points' => round($paylinePoints, 2),
|
||||
'paylines' => $paylines,
|
||||
'pp' => round($pp, 2),
|
||||
'shop' => round($shop, 2),
|
||||
'growth' => round($growth, 2),
|
||||
'is_live' => $isLive,
|
||||
'failed' => $failed,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
namespace App\Services\BusinessPlan;
|
||||
|
||||
use App\Models\UserAbo;
|
||||
use App\Services\TranslationHelper;
|
||||
use App\User;
|
||||
|
||||
/**
|
||||
* Klasse für die HTML-Darstellung von Business-Trees
|
||||
|
|
@ -11,8 +13,16 @@ use App\Services\TranslationHelper;
|
|||
class TreeHtmlRenderer
|
||||
{
|
||||
private string $initFrom;
|
||||
|
||||
private bool $forceLiveCalculation;
|
||||
|
||||
/**
|
||||
* Abo-Kennzeichen je User-ID (gebatcht geladen, nicht pro Knoten).
|
||||
*
|
||||
* @var array<int, array{own: bool, customer_count: int}>
|
||||
*/
|
||||
private array $aboBadges = [];
|
||||
|
||||
public function __construct(string $initFrom = 'member', bool $forceLiveCalculation = false)
|
||||
{
|
||||
$this->initFrom = $initFrom;
|
||||
|
|
@ -28,6 +38,8 @@ class TreeHtmlRenderer
|
|||
return '<div class="alert alert-info">Keine Business-User gefunden.</div>';
|
||||
}
|
||||
|
||||
$this->loadAboBadges($this->collectUserIds($businessUsers));
|
||||
|
||||
$html = '<ol class="dd-list">';
|
||||
foreach ($businessUsers as $businessUser) {
|
||||
$html .= $this->renderUserItem($businessUser, 0);
|
||||
|
|
@ -46,6 +58,8 @@ class TreeHtmlRenderer
|
|||
return '<div class="alert alert-info">Keine parentlosen User gefunden.</div>';
|
||||
}
|
||||
|
||||
$this->loadAboBadges($this->collectUserIds($parentless));
|
||||
|
||||
$html = '';
|
||||
foreach ($parentless as $item) {
|
||||
$html .= $this->renderParentlessItem($item);
|
||||
|
|
@ -59,14 +73,14 @@ class TreeHtmlRenderer
|
|||
*/
|
||||
public function renderSponsor($sponsor): string
|
||||
{
|
||||
if (!$sponsor) {
|
||||
return '<div class="alert alert-warning">' . __('team.no_sponsor_assigned') . '</div>';
|
||||
if (! $sponsor) {
|
||||
return '<div class="alert alert-warning">'.__('team.no_sponsor_assigned').'</div>';
|
||||
}
|
||||
|
||||
return '<li class="dd-item dd-nodrag" data-id="">' .
|
||||
'<div class="dd-handle">' .
|
||||
$this->renderUserInfo($sponsor, false, true) .
|
||||
'</div>' .
|
||||
return '<li class="dd-item dd-nodrag" data-id="">'.
|
||||
'<div class="dd-handle">'.
|
||||
$this->renderUserInfo($sponsor, false, true).
|
||||
'</div>'.
|
||||
'</li>';
|
||||
}
|
||||
|
||||
|
|
@ -91,9 +105,9 @@ class TreeHtmlRenderer
|
|||
/**
|
||||
* Rendert User Sponsor (für UserTeamCalcBot)
|
||||
*/
|
||||
public function renderUserSponsor(\App\User $sponsor): string
|
||||
public function renderUserSponsor(User $sponsor): string
|
||||
{
|
||||
if (!$sponsor || !$sponsor->account) {
|
||||
if (! $sponsor || ! $sponsor->account) {
|
||||
return '<div class="alert alert-info">Kein Sponsor gefunden.</div>';
|
||||
}
|
||||
|
||||
|
|
@ -103,19 +117,19 @@ class TreeHtmlRenderer
|
|||
|
||||
// Sponsor Info
|
||||
$html .= '<div class="col-md-3">';
|
||||
$html .= '<strong>' . e($sponsor->account->first_name . ' ' . $sponsor->account->last_name) . '</strong><br>';
|
||||
$html .= '<small>' . e($sponsor->email) . '</small>';
|
||||
$html .= '<strong>'.e($sponsor->account->first_name.' '.$sponsor->account->last_name).'</strong><br>';
|
||||
$html .= '<small>'.e($sponsor->email).'</small>';
|
||||
$html .= '</div>';
|
||||
|
||||
// Account Info
|
||||
$html .= '<div class="col-md-2">';
|
||||
$html .= '<span class="badge badge-secondary">' . e($sponsor->account->m_account ?? '') . '</span>';
|
||||
$html .= '<span class="badge badge-secondary">'.e($sponsor->account->m_account ?? '').'</span>';
|
||||
$html .= '</div>';
|
||||
|
||||
// Level Info
|
||||
$html .= '<div class="col-md-2">';
|
||||
if ($sponsor->user_level) {
|
||||
$html .= '<span class="badge badge-primary">' . e($sponsor->user_level->getLang('name')) . '</span>';
|
||||
$html .= '<span class="badge badge-primary">'.e($sponsor->user_level->getLang('name')).'</span>';
|
||||
}
|
||||
$html .= '</div>';
|
||||
|
||||
|
|
@ -136,7 +150,7 @@ class TreeHtmlRenderer
|
|||
*/
|
||||
private function renderTeamMemberItem($member, int $depth): string
|
||||
{
|
||||
$html = '<li class="dd-item" data-id="' . ($member->user_id ?? 0) . '">';
|
||||
$html = '<li class="dd-item" data-id="'.($member->user_id ?? 0).'">';
|
||||
$html .= '<div class="dd-handle">';
|
||||
$html .= '<div class="row">';
|
||||
|
||||
|
|
@ -146,19 +160,19 @@ class TreeHtmlRenderer
|
|||
// Name und Email
|
||||
$html .= '<div class="col-md-3">';
|
||||
$html .= $indent;
|
||||
$html .= '<strong>' . e(($member->first_name ?? '') . ' ' . ($member->last_name ?? '')) . '</strong><br>';
|
||||
$html .= $indent . '<small>' . e($member->email ?? '') . '</small>';
|
||||
$html .= '<strong>'.e(($member->first_name ?? '').' '.($member->last_name ?? '')).'</strong><br>';
|
||||
$html .= $indent.'<small>'.e($member->email ?? '').'</small>';
|
||||
$html .= '</div>';
|
||||
|
||||
// Account ID
|
||||
$html .= '<div class="col-md-2">';
|
||||
$html .= '<span class="badge badge-secondary">' . e($member->m_account ?? '') . '</span>';
|
||||
$html .= '<span class="badge badge-secondary">'.e($member->m_account ?? '').'</span>';
|
||||
$html .= '</div>';
|
||||
|
||||
// Level
|
||||
$html .= '<div class="col-md-2">';
|
||||
if (!empty($member->user_level_name)) {
|
||||
$html .= '<span class="badge badge-primary">' . e($member->user_level_name) . '</span>';
|
||||
if (! empty($member->user_level_name)) {
|
||||
$html .= '<span class="badge badge-primary">'.e($member->user_level_name).'</span>';
|
||||
|
||||
if ($member->next_qual_user_level) {
|
||||
$html .= '<span class="badge badge-outline-success ml-2"><i class="fa fa-arrow-up text-success" title="Karriere-Level erreicht!"></i></span>';
|
||||
|
|
@ -168,12 +182,12 @@ class TreeHtmlRenderer
|
|||
|
||||
// Qualifikation
|
||||
$html .= '<div class="col-md-2">';
|
||||
if (!empty($member->qual_kp)) {
|
||||
if (! empty($member->qual_kp)) {
|
||||
$pointsSum = (float) ($member->sales_volume_points_KP_sum ?? 0);
|
||||
$qualKP = (float) $member->qual_kp;
|
||||
$isQual = $pointsSum >= $qualKP;
|
||||
$badgeClass = $isQual ? 'badge-success' : 'badge-warning';
|
||||
$html .= '<span class="badge ' . $badgeClass . '">KU ' . $qualKP . '</span>';
|
||||
$html .= '<span class="badge '.$badgeClass.'">KU '.$qualKP.'</span>';
|
||||
}
|
||||
$html .= '</div>';
|
||||
|
||||
|
|
@ -186,7 +200,7 @@ class TreeHtmlRenderer
|
|||
$html .= '</div>';
|
||||
|
||||
// Kinder rendern
|
||||
if (!empty($member->businessUserItems) && is_array($member->businessUserItems)) {
|
||||
if (! empty($member->businessUserItems) && is_array($member->businessUserItems)) {
|
||||
$html .= '<ol class="dd-list">';
|
||||
foreach ($member->businessUserItems as $child) {
|
||||
$html .= $this->renderTeamMemberItem($child, $depth + 1);
|
||||
|
|
@ -213,11 +227,11 @@ class TreeHtmlRenderer
|
|||
$childrenHtml .= '</ol>';
|
||||
}
|
||||
|
||||
return '<li class="dd-item dd-nodrag" data-id="' . $item->user_id . '">' .
|
||||
'<div class="dd-handle">' .
|
||||
$this->renderUserCardWithDepth($item, $deep) .
|
||||
'</div>' .
|
||||
$childrenHtml .
|
||||
return '<li class="dd-item dd-nodrag" data-id="'.$item->user_id.'">'.
|
||||
'<div class="dd-handle">'.
|
||||
$this->renderUserCardWithDepth($item, $deep).
|
||||
'</div>'.
|
||||
$childrenHtml.
|
||||
'</li>';
|
||||
}
|
||||
|
||||
|
|
@ -226,10 +240,10 @@ class TreeHtmlRenderer
|
|||
*/
|
||||
private function renderParentlessItem($item): string
|
||||
{
|
||||
return '<li class="dd-item dd-nodrag" data-id="' . $item->user_id . '">' .
|
||||
'<div class="dd-handle">' .
|
||||
$this->renderUserInfo($item, true, false) .
|
||||
'</div>' .
|
||||
return '<li class="dd-item dd-nodrag" data-id="'.$item->user_id.'">'.
|
||||
'<div class="dd-handle">'.
|
||||
$this->renderUserInfo($item, true, false).
|
||||
'</div>'.
|
||||
'</li>';
|
||||
}
|
||||
|
||||
|
|
@ -240,16 +254,16 @@ class TreeHtmlRenderer
|
|||
{
|
||||
$depthBadge = '';
|
||||
if ($deep > 0) {
|
||||
$depthBadge = '<div class="d-flex flex-column justify-content-center align-items-center">' .
|
||||
'<div class="text-large font-weight-bolder line-height-1 my-2 text-secondary badge badge-outline-secondary">' . $deep . '</div>' .
|
||||
$depthBadge = '<div class="d-flex flex-column justify-content-center align-items-center">'.
|
||||
'<div class="text-large font-weight-bolder line-height-1 my-2 text-secondary badge badge-outline-secondary">'.$deep.'</div>'.
|
||||
'</div>';
|
||||
}
|
||||
|
||||
return '<div class="media align-items-center">' .
|
||||
$depthBadge .
|
||||
'<div class="media-body ml-2">' .
|
||||
$this->renderUserInfo($item, false, false) .
|
||||
'</div>' .
|
||||
return '<div class="media align-items-center">'.
|
||||
$depthBadge.
|
||||
'<div class="media-body ml-2">'.
|
||||
$this->renderUserInfo($item, false, false).
|
||||
'</div>'.
|
||||
'</div>';
|
||||
}
|
||||
|
||||
|
|
@ -263,35 +277,35 @@ class TreeHtmlRenderer
|
|||
|
||||
\Log::debug("TreeHtmlRenderer: Rendering user info for user {$item->user_id}");
|
||||
|
||||
$html = '<span class="' . $statusClass . '">';
|
||||
$html = '<span class="'.$statusClass.'">';
|
||||
|
||||
// User Link
|
||||
$html .= '<a href="#" class="text-black" data-toggle="modal" data-target="#modals-load-content" ' .
|
||||
'data-id="' . $item->user_id . '" data-action="business-user-show" data-back="" ' .
|
||||
'data-modal="modal-md" data-init_from="' . $this->initFrom . '" data-route="' . route('modal_load') . '">' .
|
||||
'<span class="mr-1 ion ion-ios-contact ' . $iconClass . '"></span> ' .
|
||||
'<strong>' . e($item->first_name . ' ' . $item->last_name) . '</strong>' .
|
||||
$html .= '<a href="#" class="text-black" data-toggle="modal" data-target="#modals-load-content" '.
|
||||
'data-id="'.$item->user_id.'" data-action="business-user-show" data-back="" '.
|
||||
'data-modal="modal-md" data-init_from="'.$this->initFrom.'" data-route="'.route('modal_load').'">'.
|
||||
'<span class="mr-1 ion ion-ios-contact '.$iconClass.'"></span> '.
|
||||
'<strong>'.e($item->first_name.' '.$item->last_name).'</strong>'.
|
||||
'</a>';
|
||||
|
||||
// Email
|
||||
$html .= ' <a href="mailto:' . e($item->email) . '">' . e($item->email) . '</a>';
|
||||
$html .= ' <a href="mailto:'.e($item->email).'">'.e($item->email).'</a>';
|
||||
|
||||
// Optional: Geburtstag
|
||||
$birthday = $item->user_birthday; // Magic Method __get() verwenden
|
||||
if ($birthday && trim($birthday) !== '') {
|
||||
$html .= ' | <i class="ion ion-ios-gift text-primary"></i> ' . e($birthday);
|
||||
$html .= ' | <i class="ion ion-ios-gift text-primary"></i> '.e($birthday);
|
||||
}
|
||||
|
||||
// Optional: Telefon
|
||||
$phone = $item->user_phone; // Magic Method __get() verwenden
|
||||
if ($phone && trim($phone) !== '') {
|
||||
$html .= ' | <i class="ion ion-ios-call text-primary"></i> ' . e($phone);
|
||||
$html .= ' | <i class="ion ion-ios-call text-primary"></i> '.e($phone);
|
||||
}
|
||||
|
||||
// Level Badge
|
||||
$levelName = $item->user_level_name ? TranslationHelper::transUserLevelName($item->user_level_name) : '';
|
||||
$account = $item->m_account ?: '';
|
||||
$html .= ' <span class="badge badge-outline-default ' . $statusClass . '">' . e($levelName . ' | ' . $account) . '</span>';
|
||||
$html .= ' <span class="badge badge-outline-default '.$statusClass.'">'.e($levelName.' | '.$account).'</span>';
|
||||
|
||||
// Karriere-Aufstiegs-Icon für qualifizierte User (nur in Struktur-Ansicht)#
|
||||
|
||||
|
|
@ -299,15 +313,18 @@ class TreeHtmlRenderer
|
|||
$html .= '<span class="badge badge-outline-success ml-2"><i class="fa fa-arrow-up text-success" title="Karriere-Level erreicht!"></i></span>';
|
||||
}
|
||||
|
||||
// Abo-Kennzeichen (eigenes Abo + eigene Kundenabos)
|
||||
$html .= $this->renderAboBadges((int) $item->user_id);
|
||||
|
||||
// Details für aktive Accounts
|
||||
if ($item->active_account) {
|
||||
$html .= '<br><span class="small">';
|
||||
if (!$isSponsor) {
|
||||
if (! $isSponsor) {
|
||||
$html .= $this->renderAccountDetails($item);
|
||||
}
|
||||
|
||||
// Action Button (außer für Sponsor-Ansicht)
|
||||
if (!$isSponsor && $this->shouldShowActionButton()) {
|
||||
if (! $isSponsor && $this->shouldShowActionButton()) {
|
||||
$html .= $this->renderActionButton($item->user_id);
|
||||
}
|
||||
|
||||
|
|
@ -315,12 +332,12 @@ class TreeHtmlRenderer
|
|||
} else {
|
||||
// Inaktive Accounts
|
||||
$paymentDate = $item->payment_account_date ?: '';
|
||||
$html .= '<br><span class="small">' . __('team.account_to') . ': ' . e($paymentDate) . '</span>';
|
||||
$html .= '<br><span class="small">'.__('team.account_to').': '.e($paymentDate).'</span>';
|
||||
}
|
||||
|
||||
// Sponsor für parentlose User
|
||||
if ($showSponsor && $item->m_sponsor_name) {
|
||||
$html .= '<br>' . e($item->m_sponsor_name);
|
||||
$html .= '<br>'.e($item->m_sponsor_name);
|
||||
}
|
||||
|
||||
$html .= '</span>';
|
||||
|
|
@ -341,12 +358,12 @@ class TreeHtmlRenderer
|
|||
$eSum = $item->sales_volume_total ?: 0;
|
||||
$sSum = $item->sales_volume_total_shop ?: 0;
|
||||
|
||||
return '<strong>' . __('team.total_points') . ': ' . formatNumber($totalPoints) . '</strong> | ' .
|
||||
__('team.e') . ': ' . formatNumber($ePoints) . ' | ' .
|
||||
__('team.s') . ': ' . formatNumber($sPoints) . ' <strong> | ' .
|
||||
__('team.net_turnover') . ': ' . formatNumber($totalSum) . ' €</strong> | ' .
|
||||
__('team.e') . ': ' . formatNumber($eSum) . ' € | ' .
|
||||
__('team.s') . ': ' . formatNumber($sSum) . ' €';
|
||||
return '<strong>'.__('team.total_points').': '.formatNumber($totalPoints).'</strong> | '.
|
||||
__('team.e').': '.formatNumber($ePoints).' | '.
|
||||
__('team.s').': '.formatNumber($sPoints).' <strong> | '.
|
||||
__('team.net_turnover').': '.formatNumber($totalSum).' €</strong> | '.
|
||||
__('team.e').': '.formatNumber($eSum).' € | '.
|
||||
__('team.s').': '.formatNumber($sSum).' €';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -354,15 +371,15 @@ class TreeHtmlRenderer
|
|||
*/
|
||||
private function renderActionButton(int $userId): string
|
||||
{
|
||||
return ' | <button type="button" class="btn icon-btn btn-xs btn-secondary" ' .
|
||||
'data-toggle="modal" data-target="#modals-load-content" ' .
|
||||
'data-id="' . $userId . '" data-action="business-user-detail" ' .
|
||||
'data-back="" data-modal="modal-xl" ' .
|
||||
'data-init_from="' . $this->initFrom . '" ' .
|
||||
'data-live="' . $this->forceLiveCalculation . '" ' .
|
||||
'data-optimized="1" ' .
|
||||
'data-route="' . route('modal_load') . '">' .
|
||||
'<span class="fa fa-calculator"></span>' .
|
||||
return ' | <button type="button" class="btn icon-btn btn-xs btn-secondary" '.
|
||||
'data-toggle="modal" data-target="#modals-load-content" '.
|
||||
'data-id="'.$userId.'" data-action="business-user-detail" '.
|
||||
'data-back="" data-modal="modal-xl" '.
|
||||
'data-init_from="'.$this->initFrom.'" '.
|
||||
'data-live="'.$this->forceLiveCalculation.'" '.
|
||||
'data-optimized="1" '.
|
||||
'data-route="'.route('modal_load').'">'.
|
||||
'<span class="fa fa-calculator"></span>'.
|
||||
'</button>';
|
||||
}
|
||||
|
||||
|
|
@ -380,12 +397,100 @@ class TreeHtmlRenderer
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt die Abo-Kennzeichen für alle übergebenen User-IDs gebatcht
|
||||
* (zwei Group-by-Queries statt Einzelabfragen pro Knoten).
|
||||
*
|
||||
* Zählweise konsistent zur Backoffice-Statistik: aktive Abos = active=true
|
||||
* und status NOT IN (4,5,6) via {@see UserAbo::scopeActiveAbo()}.
|
||||
*
|
||||
* @param array<int> $userIds
|
||||
*/
|
||||
public function loadAboBadges(array $userIds): void
|
||||
{
|
||||
$userIds = array_values(array_unique(array_filter($userIds)));
|
||||
|
||||
if (empty($userIds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$ownAboUserIds = UserAbo::query()
|
||||
->activeAbo()
|
||||
->where('is_for', 'me')
|
||||
->whereIn('user_id', $userIds)
|
||||
->distinct()
|
||||
->pluck('user_id');
|
||||
|
||||
foreach ($ownAboUserIds as $userId) {
|
||||
$this->aboBadges[(int) $userId]['own'] = true;
|
||||
}
|
||||
|
||||
$customerAboCounts = UserAbo::query()
|
||||
->activeAbo()
|
||||
->where('is_for', 'ot')
|
||||
->whereIn('member_id', $userIds)
|
||||
->groupBy('member_id')
|
||||
->selectRaw('member_id, COUNT(*) as abo_count')
|
||||
->pluck('abo_count', 'member_id');
|
||||
|
||||
foreach ($customerAboCounts as $userId => $count) {
|
||||
$this->aboBadges[(int) $userId]['customer_count'] = (int) $count;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendert die Abo-Kennzeichen für einen User:
|
||||
* grünes "A"-Badge bei eigenem aktiven Abo, rotes "(n)"-Badge bei n > 0 eigenen aktiven Kundenabos.
|
||||
*/
|
||||
public function renderAboBadges(int $userId): string
|
||||
{
|
||||
$hasOwnAbo = $this->aboBadges[$userId]['own'] ?? false;
|
||||
$customerAboCount = $this->aboBadges[$userId]['customer_count'] ?? 0;
|
||||
|
||||
$html = '';
|
||||
|
||||
if ($hasOwnAbo) {
|
||||
$html .= ' <span class="badge badge-success" title="'.e(__('team.badge_own_abo')).'">A</span>';
|
||||
}
|
||||
|
||||
if ($customerAboCount > 0) {
|
||||
$html .= ' <span class="badge badge-danger" title="'.
|
||||
e(trans_choice('team.badge_customer_abos', $customerAboCount)).'">('.$customerAboCount.')</span>';
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sammelt rekursiv alle User-IDs aus den Business-User-Items.
|
||||
*
|
||||
* @return array<int>
|
||||
*/
|
||||
private function collectUserIds(array $items): array
|
||||
{
|
||||
$userIds = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
$userId = (int) $item->user_id;
|
||||
if ($userId > 0) {
|
||||
$userIds[] = $userId;
|
||||
}
|
||||
|
||||
if (isset($item->businessUserItems) && is_array($item->businessUserItems) && count($item->businessUserItems) > 0) {
|
||||
$userIds = array_merge($userIds, $this->collectUserIds($item->businessUserItems));
|
||||
}
|
||||
}
|
||||
|
||||
return $userIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt den Kontext für die Darstellung
|
||||
*/
|
||||
public function setInitFrom(string $initFrom): self
|
||||
{
|
||||
$this->initFrom = $initFrom;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,14 +15,14 @@ class Credit
|
|||
return (int) Setting::getContentBySlug('invoice-number');
|
||||
}
|
||||
|
||||
/**
|
||||
* Vergibt atomar die nächste Gutschriftsnummer aus dem gemeinsamen
|
||||
* Nummernkreis (Setting 'invoice-number') — gleicher Lock-Mechanismus wie
|
||||
* bei Rechnungen, damit parallele Läufe keine doppelten Nummern erzeugen.
|
||||
*/
|
||||
public static function makeNextCreditNumber()
|
||||
{
|
||||
$credit_number = self::getCreditNumber();
|
||||
$credit_number = $credit_number + 1;
|
||||
// Setting::setContentBySlug('credit-number', $credit_number, 'int');
|
||||
Setting::setContentBySlug('invoice-number', $credit_number, 'int');
|
||||
|
||||
return $credit_number;
|
||||
return Invoice::makeNextInvoiceNumber();
|
||||
}
|
||||
|
||||
public static function createCreditNumber($credit_number, $credit_date)
|
||||
|
|
|
|||
|
|
@ -282,13 +282,13 @@ class DatevExportService
|
|||
$isEu = $this->isEuCountry($country);
|
||||
|
||||
$euUstid = $this->resolveEuUstid($order, $isDomestic);
|
||||
$hasValidUstid = ! empty($euUstid);
|
||||
|
||||
if (! $isDomestic && $country) {
|
||||
$euLand = $country->code ?? null;
|
||||
}
|
||||
|
||||
// Tax-Split vorhanden? -> Mehrere Zeilen pro Steuersatz
|
||||
// Tax-Split vorhanden? -> Bestellung wurde MIT USt berechnet:
|
||||
// Mehrere Zeilen pro Steuersatz, nie steuerfreie igL.
|
||||
if ($order->tax_split && is_array($order->tax_split) && count($order->tax_split) > 0) {
|
||||
$netSplit = $this->resolveNetSplit($order);
|
||||
|
||||
|
|
@ -307,7 +307,7 @@ class DatevExportService
|
|||
continue;
|
||||
}
|
||||
|
||||
$revenueMapping = $this->determineRevenueAccount($taxRate, $isDomestic, $isEu, $hasValidUstid);
|
||||
$revenueMapping = $this->determineRevenueAccount($taxRate, $isDomestic, $isEu, false);
|
||||
|
||||
$lines->push([
|
||||
'source_type' => DatevExportLine::SOURCE_INVOICE,
|
||||
|
|
@ -327,15 +327,18 @@ class DatevExportService
|
|||
]);
|
||||
}
|
||||
} else {
|
||||
// Kein Tax-Split: Gesamtbetrag als eine Zeile
|
||||
// Kein Tax-Split -> Bestellung wurde OHNE USt verkauft (igL/Drittland)
|
||||
// oder Inland-Altfall. EU-Lieferungen ohne USt sind steuerfreie igL,
|
||||
// unabhängig davon, ob die USt-ID heute noch am Account hinterlegt ist.
|
||||
$grossAmount = floatval($order->total_shipping ?? $order->total ?? 0);
|
||||
|
||||
if ($grossAmount <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$soldTaxFree = $isEu && ! $isDomestic;
|
||||
$taxRate = intval($order->tax_rate ?? 19);
|
||||
$revenueMapping = $this->determineRevenueAccount($taxRate, $isDomestic, $isEu, $hasValidUstid);
|
||||
$revenueMapping = $this->determineRevenueAccount($taxRate, $isDomestic, $isEu, $soldTaxFree);
|
||||
|
||||
$lines->push([
|
||||
'source_type' => DatevExportLine::SOURCE_INVOICE,
|
||||
|
|
@ -390,8 +393,8 @@ class DatevExportService
|
|||
$user = $credit->user;
|
||||
$account = $user ? $user->account : null;
|
||||
|
||||
// Steuerstatus des Beraters ermitteln
|
||||
$taxStatus = $this->determineCommissionTaxStatus($account);
|
||||
// Steuerstatus aus dem Snapshot der Gutschrift (nicht aktueller Account-Stand)
|
||||
$taxStatus = $this->determineCommissionTaxStatus($account, $credit);
|
||||
$buSchluessel = config('datev.commission_tax_keys.'.$taxStatus, 9);
|
||||
|
||||
// USt-ID für Reverse Charge
|
||||
|
|
@ -424,10 +427,10 @@ class DatevExportService
|
|||
$konto = $commissionAccounts[$type] ?? $commissionAccounts['shop'];
|
||||
|
||||
// Bei USt-pflichtigen Beratern: Brutto berechnen
|
||||
// (Steuersatz aus dem Snapshot der Gutschrift, wie auf dem Beleg ausgewiesen)
|
||||
$grossAmount = $totalAmount;
|
||||
if ($taxStatus === 'normal' && $credit->taxable == 1) {
|
||||
// Netto + USt = Brutto
|
||||
$taxRate = config('app.main_tax_rate', 19);
|
||||
if ($taxStatus === 'normal') {
|
||||
$taxRate = floatval($credit->tax_rate) ?: config('app.main_tax_rate', 19);
|
||||
$grossAmount = round($totalAmount * (1 + $taxRate / 100), 2);
|
||||
}
|
||||
|
||||
|
|
@ -513,7 +516,6 @@ class DatevExportService
|
|||
$isEu = $this->isEuCountry($country);
|
||||
$euLand = (! $isDomestic && $country) ? ($country->code ?? null) : null;
|
||||
$euUstid = $this->resolveEuUstid($order, $isDomestic);
|
||||
$hasValidUstid = ! empty($euUstid);
|
||||
|
||||
if ($order->tax_split && is_array($order->tax_split) && count($order->tax_split) > 0) {
|
||||
$netSplit = $this->resolveNetSplit($order);
|
||||
|
|
@ -532,7 +534,7 @@ class DatevExportService
|
|||
continue;
|
||||
}
|
||||
|
||||
$revenueMapping = $this->determineRevenueAccount($taxRate, $isDomestic, $isEu, $hasValidUstid);
|
||||
$revenueMapping = $this->determineRevenueAccount($taxRate, $isDomestic, $isEu, false);
|
||||
|
||||
// Storno: Negativer Betrag
|
||||
$lines->push([
|
||||
|
|
@ -558,8 +560,9 @@ class DatevExportService
|
|||
continue;
|
||||
}
|
||||
|
||||
$soldTaxFree = $isEu && ! $isDomestic;
|
||||
$taxRate = intval($order->tax_rate ?? 19);
|
||||
$revenueMapping = $this->determineRevenueAccount($taxRate, $isDomestic, $isEu, $hasValidUstid);
|
||||
$revenueMapping = $this->determineRevenueAccount($taxRate, $isDomestic, $isEu, $soldTaxFree);
|
||||
|
||||
$lines->push([
|
||||
'source_type' => DatevExportLine::SOURCE_CANCELLATION,
|
||||
|
|
@ -831,12 +834,15 @@ class DatevExportService
|
|||
*/
|
||||
|
||||
/**
|
||||
* Bestimmt das Erlöskonto und den BU-Schlüssel basierend auf Steuersatz, Land und USt-ID.
|
||||
* Bestimmt das Erlöskonto und den BU-Schlüssel basierend auf Steuersatz, Land
|
||||
* und der tatsächlichen Besteuerung der Bestellung.
|
||||
*
|
||||
* EU-steuerfreie IG-Lieferung (BU 1 / Konto 8125) nur wenn eine gültige USt-ID vorliegt.
|
||||
* Ohne USt-ID wird auch bei EU-Lieferungen die reguläre Inlandsbesteuerung angewendet.
|
||||
* Maßgeblich ist, wie die Rechnung ausgestellt wurde ($soldTaxFree = ohne USt
|
||||
* verkauft), nicht der aktuelle Account-Zustand: EU-Bestellungen ohne USt sind
|
||||
* steuerfreie IG-Lieferungen (BU 1 / Konto 8125); mit USt berechnete
|
||||
* EU-Bestellungen werden wie Inland gebucht.
|
||||
*/
|
||||
private function determineRevenueAccount(int $taxRate, bool $isDomestic, bool $isEu, bool $hasValidUstid = false): array
|
||||
private function determineRevenueAccount(int $taxRate, bool $isDomestic, bool $isEu, bool $soldTaxFree = false): array
|
||||
{
|
||||
$accounts = config('datev.revenue_accounts');
|
||||
|
||||
|
|
@ -844,7 +850,7 @@ class DatevExportService
|
|||
return $accounts['third_country_exempt'];
|
||||
}
|
||||
|
||||
if ($isEu && ! $isDomestic && $hasValidUstid) {
|
||||
if ($isEu && ! $isDomestic && $soldTaxFree) {
|
||||
return $accounts['eu_exempt'];
|
||||
}
|
||||
|
||||
|
|
@ -884,33 +890,55 @@ class DatevExportService
|
|||
}
|
||||
|
||||
/**
|
||||
* Bestimmt den Steuerstatus eines Beraters für die Provision.
|
||||
* Bestimmt den Steuerstatus einer Provisions-Gutschrift.
|
||||
*
|
||||
* Maßgeblich ist der taxable-Snapshot der Gutschrift (Zustand zum
|
||||
* Ausstellungszeitpunkt, wie auf dem Beleg ausgewiesen), NICHT der aktuelle
|
||||
* Account-Zustand. Nur Alt-Gutschriften ohne Snapshot (taxable=0) fallen
|
||||
* auf die Account-Logik zurück.
|
||||
*/
|
||||
private function determineCommissionTaxStatus($account): string
|
||||
private function determineCommissionTaxStatus($account, $credit = null): string
|
||||
{
|
||||
$taxable = intval($credit->taxable ?? 0);
|
||||
|
||||
if ($taxable === 1) {
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
if ($taxable === 2) {
|
||||
return 'kleinunternehmer';
|
||||
}
|
||||
|
||||
if ($taxable === 3) {
|
||||
// Ausland: EU -> Reverse Charge, Drittland -> kein BU nötig, KU als Fallback
|
||||
$country = $account?->country;
|
||||
if ($country && $country->eu_country) {
|
||||
return 'reverse_charge';
|
||||
}
|
||||
|
||||
return 'kleinunternehmer';
|
||||
}
|
||||
|
||||
// Fallback für Alt-Gutschriften ohne taxable-Snapshot
|
||||
if (! $account) {
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
// Reverse Charge hat Vorrang
|
||||
if ($account->reverse_charge) {
|
||||
return 'reverse_charge';
|
||||
}
|
||||
|
||||
// Kleinunternehmer (taxable_sales = 2)
|
||||
if ($account->taxable_sales == 2) {
|
||||
return 'kleinunternehmer';
|
||||
}
|
||||
|
||||
// Ausland nicht USt-pflichtig (taxable_sales = 3)
|
||||
// Wird wie Reverse Charge behandelt, falls aus EU
|
||||
if ($account->taxable_sales == 3) {
|
||||
$country = $account->country;
|
||||
if ($country && $country->eu_country) {
|
||||
return 'reverse_charge';
|
||||
}
|
||||
|
||||
return 'kleinunternehmer'; // Drittland: kein BU nötig, aber KU als Fallback
|
||||
return 'kleinunternehmer';
|
||||
}
|
||||
|
||||
return 'normal';
|
||||
|
|
@ -970,8 +998,8 @@ class DatevExportService
|
|||
|
||||
/**
|
||||
* Ermittelt die USt-ID des Bestellers (auth_user).
|
||||
* Nur registrierte Berater mit hinterlegter USt-ID qualifizieren sich
|
||||
* für steuerfreie innergemeinschaftliche Lieferungen.
|
||||
* Deutsche oder formal unplausible USt-IDs werden verworfen — sie
|
||||
* qualifizieren nie für steuerfreie innergemeinschaftliche Lieferungen.
|
||||
*/
|
||||
private function resolveEuUstid($order, bool $isDomestic): ?string
|
||||
{
|
||||
|
|
@ -979,7 +1007,13 @@ class DatevExportService
|
|||
return null;
|
||||
}
|
||||
|
||||
return $order->auth_user?->account?->tax_identification_number ?: null;
|
||||
$ustid = $order->auth_user?->account?->tax_identification_number ?: null;
|
||||
|
||||
if ($ustid && ! app(VatIdService::class)->qualifiesForReverseCharge($ustid)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $ustid;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1064,10 +1098,11 @@ class DatevExportService
|
|||
}
|
||||
|
||||
/**
|
||||
* Parst einen formatierten Zahlenwert (z.B. "5.00" oder "5,00") zu float.
|
||||
* Parst einen formatierten Zahlenwert (z.B. "5.00", "5,00" oder "1,434.70") zu float.
|
||||
* Behandelt sowohl einfache Werte als auch tax_split Arrays (homeparty).
|
||||
* Tausendertrennzeichen ("1,434.70" oder "1.434,70") werden korrekt entfernt.
|
||||
*/
|
||||
private function parseNumber($value, ?string $preferredArrayKey = null): float
|
||||
public function parseNumber($value, ?string $preferredArrayKey = null): float
|
||||
{
|
||||
if (is_array($value)) {
|
||||
$arrayKeys = $this->getSplitArrayKeys($preferredArrayKey);
|
||||
|
|
@ -1081,7 +1116,38 @@ class DatevExportService
|
|||
return 0.0;
|
||||
}
|
||||
|
||||
return floatval(str_replace(',', '.', $value));
|
||||
if (is_int($value) || is_float($value)) {
|
||||
return (float) $value;
|
||||
}
|
||||
|
||||
$value = trim((string) $value);
|
||||
if ($value === '') {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$hasComma = str_contains($value, ',');
|
||||
$hasDot = str_contains($value, '.');
|
||||
|
||||
if ($hasComma && $hasDot) {
|
||||
if (strrpos($value, ',') > strrpos($value, '.')) {
|
||||
// Deutsches Format: 1.434,70
|
||||
$value = str_replace('.', '', $value);
|
||||
$value = str_replace(',', '.', $value);
|
||||
} else {
|
||||
// US-Format: 1,434.70
|
||||
$value = str_replace(',', '', $value);
|
||||
}
|
||||
} elseif ($hasComma) {
|
||||
if (preg_match('/^-?\d{1,3}(,\d{3})+$/', $value)) {
|
||||
// Nur Tausendertrenner: 1,434
|
||||
$value = str_replace(',', '', $value);
|
||||
} else {
|
||||
// Komma als Dezimaltrenner: 5,00
|
||||
$value = str_replace(',', '.', $value);
|
||||
}
|
||||
}
|
||||
|
||||
return floatval($value);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
353
app/Services/GdprDeletionService.php
Normal file
353
app/Services/GdprDeletionService.php
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\GdprDeletionException;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Models\UserAbo;
|
||||
use App\Models\UserAccount;
|
||||
use App\Models\UserBusiness;
|
||||
use App\Models\UserShop;
|
||||
use App\Models\UserUpdateEmail;
|
||||
use App\User;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* DSGVO-Löschung (Art. 17 DSGVO) für Berater (User) und Endkunden (ShoppingUser).
|
||||
*
|
||||
* Personenbezogene Daten werden unwiderruflich anonymisiert. Daten, die unter
|
||||
* gesetzliche Aufbewahrungspflichten fallen (Rechnungen, Gutschriften,
|
||||
* DATEV-Exporte, Provisionsabrechnungen, Bestellungen mit Beleg), bleiben
|
||||
* gemäß Art. 17 Abs. 3 lit. b DSGVO erhalten:
|
||||
* - Rechnungs-/Gutschrift-PDFs und deren DB-Zeilen werden nicht verändert.
|
||||
* - Bei Kunden-Datensätzen (shopping_users) mit Bestellungen bleiben Name und
|
||||
* Rechnungsadresse erhalten (Buchungsbeleg), Kontaktdaten werden entfernt.
|
||||
* - Provisions-Snapshots (user_businesses) behalten die Zahlen, die
|
||||
* denormalisierten Personendaten werden anonymisiert.
|
||||
*/
|
||||
class GdprDeletionService
|
||||
{
|
||||
public const ANONYMIZED_FIRSTNAME = 'Gelöscht';
|
||||
|
||||
public const ANONYMIZED_LASTNAME = 'DSGVO';
|
||||
|
||||
/**
|
||||
* Berater (User) DSGVO-konform löschen.
|
||||
*
|
||||
* @return array<string, int|string> Zusammenfassung der ausgeführten Schritte
|
||||
*
|
||||
* @throws GdprDeletionException wenn Sperrgründe vorliegen (Admin-Account, aktives Abo)
|
||||
*/
|
||||
public function deleteUser(User $user, ?int $executedByUserId = null): array
|
||||
{
|
||||
if ($user->admin >= 2) {
|
||||
throw new GdprDeletionException(__('msg.gdpr_error_is_admin'));
|
||||
}
|
||||
|
||||
$activeAbos = UserAbo::where('user_id', $user->id)->where('active', true)->count();
|
||||
if ($activeAbos > 0) {
|
||||
throw new GdprDeletionException(__('msg.gdpr_error_active_abo'));
|
||||
}
|
||||
|
||||
$originalEmail = $user->email;
|
||||
|
||||
$summary = DB::transaction(function () use ($user) {
|
||||
$summary = ['user_id' => $user->id];
|
||||
|
||||
// MLM-Struktur erhalten: Downline und Kunden auf den nächsten aktiven Sponsor
|
||||
// übertragen. Bewusst beim Sponsor starten, nicht beim User selbst — ein noch
|
||||
// aktiver User würde sonst sich selbst als neuen Sponsor erhalten.
|
||||
$active_sponsor = UserUtil::findNextActiveSponsor($user->m_sponsor ?: $user->pre_sponsor);
|
||||
if ($active_sponsor && $active_sponsor->id !== $user->id) {
|
||||
UserUtil::setNewSponsorToChilds($user->id, $active_sponsor->id);
|
||||
UserUtil::setShoppingUserToNewMember($user->id, $active_sponsor->id);
|
||||
$summary['new_sponsor_id'] = $active_sponsor->id;
|
||||
}
|
||||
|
||||
// Bestehende Komplett-Löschung: E-Mail randomisieren, Zugangsdaten entwerten,
|
||||
// Account + Shop soft-deleten
|
||||
UserUtil::deleteUser($user, true);
|
||||
|
||||
$this->anonymizeUserRow($user);
|
||||
$this->anonymizeUserAccount($user);
|
||||
$this->anonymizeUserShop($user);
|
||||
$summary['files_deleted'] = $this->deleteUserFiles($user);
|
||||
$summary['update_emails_deleted'] = UserUpdateEmail::where('user_id', $user->id)->delete();
|
||||
$summary['abos_anonymized'] = $this->anonymizeUserAbos('user_id', $user->id);
|
||||
$summary['business_rows_anonymized'] = $this->anonymizeUserBusinessRows($user);
|
||||
$summary['shopping_users_anonymized'] = $this->anonymizeShoppingUserRows(
|
||||
ShoppingUser::withTrashed()->where('auth_user_id', $user->id)->get()
|
||||
);
|
||||
|
||||
return $summary;
|
||||
});
|
||||
|
||||
$this->logDeletion('gdpr-delete-user', $user->id, User::class, $executedByUserId, $originalEmail, $summary);
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Endkunden (ShoppingUser) DSGVO-konform löschen.
|
||||
*
|
||||
* Anonymisiert alle Datensätze des Kunden (Einträge mit gleicher Kundennummer).
|
||||
*
|
||||
* @return array<string, int|string>
|
||||
*
|
||||
* @throws GdprDeletionException wenn der Datensatz zu einem Berater gehört oder ein aktives Abo besteht
|
||||
*/
|
||||
public function deleteShoppingUser(ShoppingUser $shoppingUser, ?int $executedByUserId = null): array
|
||||
{
|
||||
if ($shoppingUser->auth_user_id) {
|
||||
throw new GdprDeletionException(__('msg.gdpr_error_customer_is_member'));
|
||||
}
|
||||
|
||||
$rows = $this->getCustomerRows($shoppingUser);
|
||||
|
||||
$activeAbos = UserAbo::whereIn('shopping_user_id', $rows->pluck('id'))->where('active', true)->count();
|
||||
if ($activeAbos > 0) {
|
||||
throw new GdprDeletionException(__('msg.gdpr_error_active_abo'));
|
||||
}
|
||||
|
||||
$originalEmail = $shoppingUser->billing_email;
|
||||
|
||||
$summary = DB::transaction(function () use ($shoppingUser, $rows) {
|
||||
$summary = ['shopping_user_id' => $shoppingUser->id];
|
||||
$summary['abos_anonymized'] = 0;
|
||||
foreach ($rows as $row) {
|
||||
$summary['abos_anonymized'] += $this->anonymizeUserAbos('shopping_user_id', $row->id);
|
||||
}
|
||||
$summary['shopping_users_anonymized'] = $this->anonymizeShoppingUserRows($rows);
|
||||
|
||||
return $summary;
|
||||
});
|
||||
|
||||
$this->logDeletion('gdpr-delete-customer', $shoppingUser->id, ShoppingUser::class, $executedByUserId, $originalEmail, $summary);
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Datensätze eines Endkunden (gleiche Kundennummer), inkl. soft-deleted.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Collection<int, ShoppingUser>
|
||||
*/
|
||||
public function getCustomerRows(ShoppingUser $shoppingUser)
|
||||
{
|
||||
if ($shoppingUser->number > 0) {
|
||||
return ShoppingUser::withTrashed()->where('number', $shoppingUser->number)->get();
|
||||
}
|
||||
|
||||
return ShoppingUser::withTrashed()->where('id', $shoppingUser->id)->get();
|
||||
}
|
||||
|
||||
protected function anonymizeUserRow(User $user): void
|
||||
{
|
||||
$user->notes = null;
|
||||
$user->settings = null;
|
||||
$user->payment_methods = null;
|
||||
$user->last_login = null;
|
||||
$user->save();
|
||||
}
|
||||
|
||||
protected function anonymizeUserAccount(User $user): void
|
||||
{
|
||||
if (! $user->account_id) {
|
||||
return;
|
||||
}
|
||||
$account = UserAccount::withTrashed()->find($user->account_id);
|
||||
if (! $account) {
|
||||
return;
|
||||
}
|
||||
|
||||
$account->m_salutation = null;
|
||||
$account->m_first_name = self::ANONYMIZED_FIRSTNAME;
|
||||
$account->m_last_name = self::ANONYMIZED_LASTNAME;
|
||||
$account->m_notes = null;
|
||||
$account->company = null;
|
||||
$account->salutation = null;
|
||||
$account->first_name = self::ANONYMIZED_FIRSTNAME;
|
||||
$account->last_name = self::ANONYMIZED_LASTNAME;
|
||||
$account->address = null;
|
||||
$account->address_2 = null;
|
||||
$account->zipcode = null;
|
||||
$account->city = null;
|
||||
$account->pre_phone_id = null;
|
||||
$account->phone = null;
|
||||
$account->pre_mobil_id = null;
|
||||
$account->mobil = null;
|
||||
$account->tax_number = null;
|
||||
$account->tax_identification_number = null;
|
||||
$account->shipping_salutation = null;
|
||||
$account->shipping_company = null;
|
||||
$account->shipping_firstname = null;
|
||||
$account->shipping_lastname = null;
|
||||
$account->shipping_address = null;
|
||||
$account->shipping_address_2 = null;
|
||||
$account->shipping_zipcode = null;
|
||||
$account->shipping_city = null;
|
||||
$account->shipping_pre_phone_id = null;
|
||||
$account->shipping_phone = null;
|
||||
$account->shipping_postnumber = null;
|
||||
$account->birthday = null;
|
||||
$account->website = null;
|
||||
$account->facebook = null;
|
||||
$account->facebook_fanpage = null;
|
||||
$account->instagram = null;
|
||||
$account->bank_owner = null;
|
||||
$account->bank_iban = null;
|
||||
$account->bank_bic = null;
|
||||
$account->payment_data = null;
|
||||
$account->notice = null;
|
||||
$account->save();
|
||||
}
|
||||
|
||||
protected function anonymizeUserShop(User $user): void
|
||||
{
|
||||
$shop = UserShop::withTrashed()->where('user_id', $user->id)->first();
|
||||
if (! $shop) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($shop->filename) {
|
||||
Storage::disk('public')->delete('images/shop/'.$shop->filename);
|
||||
}
|
||||
$shop->title = null;
|
||||
$shop->contact = null;
|
||||
$shop->accessibility = null;
|
||||
$shop->about = null;
|
||||
$shop->trans = null;
|
||||
$shop->filename = null;
|
||||
$shop->originalname = null;
|
||||
$shop->ext = null;
|
||||
$shop->mine = null;
|
||||
$shop->size = null;
|
||||
$shop->save();
|
||||
}
|
||||
|
||||
protected function deleteUserFiles(User $user): int
|
||||
{
|
||||
$count = 0;
|
||||
foreach ($user->files()->get() as $file) {
|
||||
Storage::disk('user')->delete($file->dir.$file->filename);
|
||||
$file->delete();
|
||||
$count++;
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zahlungs-Tokens und Kontaktdaten aller Abos entfernen (auch soft-deleted).
|
||||
*/
|
||||
protected function anonymizeUserAbos(string $column, int $id): int
|
||||
{
|
||||
$count = 0;
|
||||
$abos = UserAbo::withTrashed()->where($column, $id)->get();
|
||||
foreach ($abos as $abo) {
|
||||
$abo->email = null;
|
||||
$abo->carddata = null;
|
||||
$abo->payone_userid = 0;
|
||||
$abo->stripe_customer_id = null;
|
||||
$abo->stripe_payment_method_id = null;
|
||||
$abo->active = false;
|
||||
$abo->save();
|
||||
$count++;
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Denormalisierte Personendaten in den monatlichen Provisions-Snapshots
|
||||
* anonymisieren; die Finanzdaten (Umsätze, Provisionen) bleiben erhalten.
|
||||
*/
|
||||
protected function anonymizeUserBusinessRows(User $user): int
|
||||
{
|
||||
return UserBusiness::where('user_id', $user->id)->update([
|
||||
'email' => null,
|
||||
'first_name' => self::ANONYMIZED_FIRSTNAME,
|
||||
'last_name' => self::ANONYMIZED_LASTNAME,
|
||||
'user_birthday' => null,
|
||||
'user_phone' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kunden-Datensätze anonymisieren. Datensätze mit Bestellungen behalten
|
||||
* Name und Rechnungsadresse (Aufbewahrungspflicht Buchungsbeleg),
|
||||
* verlieren aber alle Kontaktdaten. Datensätze ohne Bestellung werden
|
||||
* vollständig anonymisiert.
|
||||
*
|
||||
* @param Collection<int, ShoppingUser> $rows
|
||||
*/
|
||||
protected function anonymizeShoppingUserRows($rows): int
|
||||
{
|
||||
$count = 0;
|
||||
foreach ($rows as $row) {
|
||||
$hasOrders = $row->shopping_orders()->withTrashed()->count() > 0;
|
||||
if ($hasOrders) {
|
||||
$this->anonymizeShoppingUserContactData($row);
|
||||
} else {
|
||||
$this->anonymizeShoppingUserComplete($row);
|
||||
}
|
||||
$count++;
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
protected function anonymizeShoppingUserContactData(ShoppingUser $row): void
|
||||
{
|
||||
$row->billing_phone = null;
|
||||
$row->billing_email = 'geloescht_'.$row->id.'@dsgvo.invalid';
|
||||
$row->shipping_phone = null;
|
||||
$row->shipping_email = null;
|
||||
$row->remarks = '';
|
||||
$row->notice = [];
|
||||
$row->subscribed = false;
|
||||
$row->faker_mail = true;
|
||||
$row->save();
|
||||
}
|
||||
|
||||
protected function anonymizeShoppingUserComplete(ShoppingUser $row): void
|
||||
{
|
||||
$row->billing_salutation = null;
|
||||
$row->billing_company = null;
|
||||
$row->billing_firstname = self::ANONYMIZED_FIRSTNAME;
|
||||
$row->billing_lastname = self::ANONYMIZED_LASTNAME;
|
||||
$row->billing_address = null;
|
||||
$row->billing_address_2 = null;
|
||||
$row->billing_zipcode = null;
|
||||
$row->billing_city = null;
|
||||
$row->shipping_salutation = null;
|
||||
$row->shipping_company = null;
|
||||
$row->shipping_firstname = null;
|
||||
$row->shipping_lastname = null;
|
||||
$row->shipping_address = null;
|
||||
$row->shipping_address_2 = null;
|
||||
$row->shipping_zipcode = null;
|
||||
$row->shipping_city = null;
|
||||
$row->shipping_postnumber = null;
|
||||
$this->anonymizeShoppingUserContactData($row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nachweis der Löschung (Art. 5 Abs. 2 DSGVO) ohne personenbezogene Daten.
|
||||
*
|
||||
* @param array<string, int|string> $summary
|
||||
*/
|
||||
protected function logDeletion(string $action, int $modelId, string $modelClass, ?int $executedByUserId, ?string $originalEmail, array $summary): void
|
||||
{
|
||||
SysLog::action($action, 'gdpr', 4)
|
||||
->setUserId($executedByUserId)
|
||||
->setModel($modelId, $modelClass)
|
||||
->setMessage('DSGVO-Löschung ausgeführt. Umfang: '.json_encode($summary))
|
||||
->save();
|
||||
|
||||
Log::channel('cleanup')->info($action.' id:'.$modelId.' email-hash:'.hash('sha256', (string) $originalEmail).' summary:'.json_encode($summary));
|
||||
}
|
||||
}
|
||||
|
|
@ -316,12 +316,15 @@ class IncentiveTracker
|
|||
|
||||
/**
|
||||
* Update ranking for all participants of an incentive.
|
||||
* Der Rang entspricht der Anzeigereihenfolge des Leaderboards: Qualifizierte zuerst (1..n),
|
||||
* dahinter Nicht-Qualifizierte, jeweils nach Punkten absteigend.
|
||||
*/
|
||||
public static function updateRanking(Incentive $incentive): void
|
||||
{
|
||||
// Nur Teilnehmer mit Punkten bekommen einen Rang (bei Punktgleichstand: Teilnahme bestaetigt vor anonym)
|
||||
$with_points = IncentiveParticipant::where('incentive_id', $incentive->id)
|
||||
->where('total_points', '>', 0)
|
||||
->orderByDesc('is_qualified')
|
||||
->orderByDesc('total_points')
|
||||
->orderByRaw('accepted_terms_at IS NOT NULL DESC')
|
||||
->get();
|
||||
|
|
|
|||
|
|
@ -1,198 +0,0 @@
|
|||
<?php
|
||||
namespace App\Services\SyS;
|
||||
|
||||
use Request;
|
||||
use App\Models\UserShop;
|
||||
use App\Http\Controllers\Api\KasController;
|
||||
use App\Http\Controllers\Api\KasSLLController;
|
||||
|
||||
class DomainSSL
|
||||
{
|
||||
|
||||
public static function show()
|
||||
{
|
||||
$user_shops = UserShop::limit(1000)->get();
|
||||
$text = "";
|
||||
|
||||
$kas = new KasController();
|
||||
//$domain = 'mivita.care';
|
||||
// $ssl = KasSLLController::getApiSSLParameter();
|
||||
$SubDomains = [];
|
||||
|
||||
$get_subdomains = $kas->action('get_subdomains');
|
||||
foreach ($get_subdomains as $subdomain){
|
||||
if(isset($subdomain['subdomain_name'])){
|
||||
if(strpos($subdomain['subdomain_name'], 'www.') !== false){
|
||||
continue;
|
||||
}
|
||||
if(strpos($subdomain['subdomain_name'], 'api.') !== false){
|
||||
continue;
|
||||
}
|
||||
if(strpos($subdomain['subdomain_name'], 'checkout.') !== false){
|
||||
continue;
|
||||
}
|
||||
$SubDomains[$subdomain['subdomain_name']] =
|
||||
[
|
||||
'ssl_certificate_sni' => $subdomain['ssl_certificate_sni'],
|
||||
'php_version' => $subdomain['php_version'],
|
||||
];
|
||||
|
||||
}else{
|
||||
@dump($subdomain);
|
||||
}
|
||||
|
||||
|
||||
/* if($subdomain['ssl_certificate_sni'] !== "Y"){
|
||||
$pra = array(
|
||||
'hostname' => $subdomain['subdomain_name'],
|
||||
);
|
||||
$pra = array_merge($pra, $ssl);
|
||||
$value = $kas->action('update_ssl', $pra);
|
||||
$text .= $value;
|
||||
}else{
|
||||
if(isset($subdomain['ssl_certificate_sni_is_active'])){
|
||||
$text .= $subdomain['ssl_certificate_sni_is_active'].'-is_active';
|
||||
}else{
|
||||
$text .= '-CHECK!';
|
||||
|
||||
}
|
||||
}*/
|
||||
|
||||
//$text .= "\n";
|
||||
}
|
||||
|
||||
$dobbleDomains = [];
|
||||
|
||||
foreach($user_shops as $user_shop){
|
||||
$user_shop->hasSubdomain = false;
|
||||
$user_shop->hasSSL = false;
|
||||
$user_shop->PHPversion = "";
|
||||
if(array_key_exists($user_shop->slug.'.mivita.care', $SubDomains)){
|
||||
$user_shop->hasSubdomain = true;
|
||||
$user_shop->hasSSL = $SubDomains[$user_shop->slug.'.mivita.care']['ssl_certificate_sni'] === 'Y' ? true : false;
|
||||
$user_shop->PHPversion = $SubDomains[$user_shop->slug.'.mivita.care']['php_version'];
|
||||
unset($SubDomains[$user_shop->slug.'.mivita.care']);
|
||||
}
|
||||
$dobbleDomains[$user_shop->user_id][$user_shop->id] = $user_shop->slug.'.mivita.care';
|
||||
}
|
||||
foreach($dobbleDomains as $key => $dobbleDomain){
|
||||
if(count($dobbleDomain) === 1){
|
||||
unset($dobbleDomains[$key]);
|
||||
}
|
||||
}
|
||||
$data = [
|
||||
'values' => $user_shops,
|
||||
'text' => $text,
|
||||
'SubDomains' => $SubDomains,
|
||||
'dobbleDomains' => $dobbleDomains,
|
||||
|
||||
];
|
||||
|
||||
return view('sys.tools.domain-ssl', $data);
|
||||
}
|
||||
|
||||
|
||||
public static function store()
|
||||
{
|
||||
$data = Request::all();
|
||||
if(isset($data['delete_sub_kas'])){
|
||||
$kas = new KasController();
|
||||
$pra = array(
|
||||
'subdomain_name' => $data['delete_sub_kas'],
|
||||
);
|
||||
$value = $kas->action('delete_subdomain', $pra);
|
||||
\Session()->flash('alert-success', 'subdomain: '.$value.' gelöscht');
|
||||
|
||||
}
|
||||
if(isset($data['delete_user_shop'])){
|
||||
$user_shop = UserShop::findOrFail($data['delete_user_shop']);
|
||||
$subdomain_name = $user_shop->slug.'.mivita.care';
|
||||
$user_shop->name = "delete".$user_shop->id;
|
||||
$user_shop->slug = "delete".$user_shop->id;
|
||||
$user_shop->save();
|
||||
$user_shop->delete();
|
||||
$kas = new KasController();
|
||||
$pra = array(
|
||||
'subdomain_name' => $subdomain_name,
|
||||
);
|
||||
$value = $kas->action('delete_subdomain', $pra);
|
||||
\Session()->flash('alert-success', 'shop/subdomain: '.$value.' gelöscht');
|
||||
|
||||
}
|
||||
|
||||
if(isset($data['update_ssl'])){
|
||||
|
||||
//prüfen ob subdomain existiert
|
||||
$user_shop = UserShop::findOrFail($data['update_ssl']);
|
||||
$subdomain_name = $user_shop->slug.'.mivita.care';
|
||||
|
||||
$kas = new KasController();
|
||||
$subdomain = $kas->action('get_subdomains', ['subdomain_name' => $subdomain_name]);
|
||||
if(!isset($subdomain[0]['subdomain_name'])){ //not found
|
||||
self::makeNewSubDomain($user_shop->slug);
|
||||
\Session()->flash('alert-danger', 'subdomain: '.$subdomain_name.' nicht gefunden');
|
||||
return back();
|
||||
}else{
|
||||
self::updateSubDomain($subdomain_name);
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
private static function updateSubDomain($subdomain_name){
|
||||
|
||||
$kas = new KasController();
|
||||
$ssl = KasSLLController::getApiSSLParameter();
|
||||
$pra = array(
|
||||
'hostname' => $subdomain_name
|
||||
);
|
||||
$pra = array_merge($pra, $ssl);
|
||||
$value = $kas->action('update_ssl', $pra);
|
||||
\Session()->flash('alert-success', 'update SSL: '.$value.'');
|
||||
return $value;
|
||||
}
|
||||
|
||||
private static function makeNewSubDomain($slug){
|
||||
|
||||
$kas = new KasController();
|
||||
$domain = 'mivita.care';
|
||||
|
||||
//check if exisist
|
||||
$subdomains = $kas->action('get_subdomains');
|
||||
foreach ($subdomains as $subdomain){
|
||||
if(!isset($subdomain['subdomain_name'])){
|
||||
continue;
|
||||
}
|
||||
$sub = str_replace(".".$domain, '', $subdomain['subdomain_name']);
|
||||
if($sub == $slug){
|
||||
\Session()->flash('alert-danger', __('shop.error_subdomain_exists'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
//add
|
||||
$full_subdomain_name = $slug.".".$domain;
|
||||
$pra = array(
|
||||
'subdomain_name' => $slug,
|
||||
'domain_name' => $domain,
|
||||
'subdomain_path' => '/mein.mivita.care/public/',
|
||||
'php_version' => config('app.php_version'),
|
||||
//'ssl_proxy' => 'Y',
|
||||
//'redirect_status' => 0
|
||||
);
|
||||
$add_subdomain = $kas->action('add_subdomain', $pra);
|
||||
if($add_subdomain == $full_subdomain_name){
|
||||
\Session()->flash('alert-success', 'Add Subdomain: '.$add_subdomain.'');
|
||||
return $add_subdomain;
|
||||
}
|
||||
\Session()->flash('alert-danger', 'Fehler: '.$add_subdomain.'');
|
||||
return $add_subdomain;
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +1,13 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\SyS;
|
||||
|
||||
use Request;
|
||||
use App\Services\Shop;
|
||||
use App\Models\UserShop;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Services\ShoppingUserService;
|
||||
use App\Http\Controllers\Api\KasController;
|
||||
use App\Http\Controllers\Api\KasSLLController;
|
||||
use Request;
|
||||
|
||||
class ShoppingOrders
|
||||
{
|
||||
|
||||
public static function show()
|
||||
{
|
||||
abort(403, 'STOP funtion not online');
|
||||
|
|
@ -21,32 +17,31 @@ class ShoppingOrders
|
|||
'values' => $shopping_users,
|
||||
'text' => '',
|
||||
];
|
||||
|
||||
return view('sys.tools.shopping-orders', $data);
|
||||
}
|
||||
|
||||
|
||||
public static function store()
|
||||
{
|
||||
abort(403, 'STOP funtion not online');
|
||||
dd("");
|
||||
|
||||
dd('');
|
||||
|
||||
$data = Request::all();
|
||||
|
||||
if($data['action'] === 'first_run'){
|
||||
$shopping_users = ShoppingUser::whereHas('shopping_order', function($q) {
|
||||
|
||||
if ($data['action'] === 'first_run') {
|
||||
$shopping_users = ShoppingUser::whereHas('shopping_order', function ($q) {
|
||||
$q->where('txaction', 'paid')->OrWhere('txaction', 'appointed');
|
||||
})->get();
|
||||
|
||||
$order_email = [];
|
||||
$order_number = [];
|
||||
|
||||
foreach ($shopping_users as $shopping_user){
|
||||
foreach ($shopping_users as $shopping_user) {
|
||||
$order_email[$shopping_user->billing_email] = isset($order_email[$shopping_user->billing_email]) ? $order_email[$shopping_user->billing_email] + 1 : 1;
|
||||
if($shopping_user->number) {
|
||||
if ($shopping_user->number) {
|
||||
$order_number[$shopping_user->number] = isset($order_number[$shopping_user->number]) ? $order_number[$shopping_user->number] + 1 : 1;
|
||||
$shopping_user->orders = $order_number[$shopping_user->number];
|
||||
}else {
|
||||
} else {
|
||||
$shopping_user->orders = $order_email[$shopping_user->billing_email];
|
||||
}
|
||||
$shopping_user->save();
|
||||
|
|
@ -55,11 +50,11 @@ class ShoppingOrders
|
|||
\Session()->flash('alert-save', true);
|
||||
}
|
||||
|
||||
if($data['action'] === 'next_run'){
|
||||
if ($data['action'] === 'next_run') {
|
||||
ShoppingUserService::snycOrdersByShoppingUser($shopping_user);
|
||||
\Session()->flash('alert-save', true);
|
||||
}
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -121,6 +121,10 @@ class UserService
|
|||
if ($user->account->reverse_charge && $user->account->reverse_charge_valid) {
|
||||
// Rechnungsland ist auch Lieferland, dann RSV
|
||||
if (strtolower($user->account->reverse_charge_code) == strtolower($ShippingCountry->country->code)) {
|
||||
// USt-ID bei jeder Verwendung erneut prüfen (VIES, gecacht)
|
||||
if (! self::verifyReverseChargeVatId($user)) {
|
||||
return false;
|
||||
}
|
||||
self::$user_reverse_charge = true;
|
||||
|
||||
return true;
|
||||
|
|
@ -132,6 +136,49 @@ class UserService
|
|||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft die hinterlegte USt-ID bei jeder Verwendung erneut.
|
||||
* Deutsche, formal ungültige oder laut VIES ungültige USt-IDs verlieren den
|
||||
* Reverse-Charge-Status; bei VIES-Ausfall gilt der gespeicherte Status weiter.
|
||||
*/
|
||||
public static function verifyReverseChargeVatId(User $user): bool
|
||||
{
|
||||
$vatService = app(VatIdService::class);
|
||||
$vatId = $user->account->tax_identification_number;
|
||||
|
||||
if (! $vatService->qualifiesForReverseCharge($vatId)) {
|
||||
self::revokeReverseCharge($user);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$result = $vatService->checkViesCached($vatId, $user->account->country->code ?? null);
|
||||
|
||||
if ($result === VatIdService::CHECK_INVALID) {
|
||||
self::revokeReverseCharge($user);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// valid oder VIES nicht erreichbar (Fallback auf gespeicherten Status)
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entzieht den Reverse-Charge-Status und informiert den User im Checkout,
|
||||
* dass die Bestellung mit Umsatzsteuer berechnet wird.
|
||||
*/
|
||||
private static function revokeReverseCharge(User $user): void
|
||||
{
|
||||
$user->account->reverse_charge = 0;
|
||||
$user->account->reverse_charge_valid = null;
|
||||
$user->account->save();
|
||||
|
||||
if (app()->bound('request') && request()->hasSession()) {
|
||||
request()->session()->flash('alert-error', __('msg.VATID_no_longer_valid_taxes_applied'));
|
||||
}
|
||||
}
|
||||
|
||||
public static function getYardInfo()
|
||||
{
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Http\Controllers\Api\KasController;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Models\ShoppingUserMemberLog;
|
||||
use App\Models\UserCleanUpLog;
|
||||
|
|
@ -171,19 +170,10 @@ class UserUtil
|
|||
{
|
||||
// shop wird gelöscht
|
||||
if ($user->shop) {
|
||||
// $subdomain_name = $user->shop->slug . '.mivita.care';
|
||||
$user->shop->name = 'delete'.$user->shop->id;
|
||||
$user->shop->slug = 'delete'.$user->shop->id;
|
||||
$user->shop->save();
|
||||
$user->shop->delete();
|
||||
// isset KAS - delete Subdomain
|
||||
/*if (!Util::isTestSystem()) {
|
||||
$kas = new KasController();
|
||||
$pra = array(
|
||||
'subdomain_name' => $subdomain_name,
|
||||
);
|
||||
$kas->action('delete_subdomain', $pra);
|
||||
}*/
|
||||
}
|
||||
|
||||
// user soll nicht komplett gelöscht werden
|
||||
|
|
|
|||
173
app/Services/VatIdService.php
Normal file
173
app/Services/VatIdService.php
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Zentrale Prüfung von Umsatzsteuer-Identifikationsnummern (USt-ID).
|
||||
*
|
||||
* - Formatprüfung (EU-Länderpräfix + Nummernteil)
|
||||
* - Deutsche USt-IDs qualifizieren nie für Reverse-Charge / steuerfreie igL
|
||||
* - Live-Prüfung gegen den EU-VIES-Dienst (mit Cache, da VIES langsam/instabil ist)
|
||||
*/
|
||||
class VatIdService
|
||||
{
|
||||
public const CHECK_VALID = 'valid';
|
||||
|
||||
public const CHECK_INVALID = 'invalid';
|
||||
|
||||
public const CHECK_UNAVAILABLE = 'unavailable';
|
||||
|
||||
private const VIES_WSDL = 'https://ec.europa.eu/taxation_customs/vies/checkVatService.wsdl';
|
||||
|
||||
/**
|
||||
* VIES-Ländercodes (EL = Griechenland, XI = Nordirland).
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const EU_COUNTRY_CODES = [
|
||||
'AT', 'BE', 'BG', 'CY', 'CZ', 'DE', 'DK', 'EE', 'EL', 'ES', 'FI', 'FR', 'HR', 'HU',
|
||||
'IE', 'IT', 'LT', 'LU', 'LV', 'MT', 'NL', 'PL', 'PT', 'RO', 'SE', 'SI', 'SK', 'XI',
|
||||
];
|
||||
|
||||
/**
|
||||
* Entfernt Leerzeichen, Punkte, Kommas und Bindestriche und wandelt in Großbuchstaben um.
|
||||
*/
|
||||
public function normalize(?string $vatId): string
|
||||
{
|
||||
return strtoupper(str_replace([' ', '.', '-', ','], '', trim((string) $vatId)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert den Ländercode aus dem USt-ID-Präfix, sonst den Fallback (Account-Land).
|
||||
*/
|
||||
public function extractCountryCode(?string $vatId, ?string $fallbackCountryCode = null): ?string
|
||||
{
|
||||
$normalized = $this->normalize($vatId);
|
||||
$prefix = substr($normalized, 0, 2);
|
||||
|
||||
if (in_array($prefix, self::EU_COUNTRY_CODES, true)) {
|
||||
return $prefix;
|
||||
}
|
||||
|
||||
$fallback = strtoupper((string) $fallbackCountryCode);
|
||||
|
||||
return in_array($fallback, self::EU_COUNTRY_CODES, true) ? $fallback : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert den Nummernteil ohne Ländercode-Präfix.
|
||||
*/
|
||||
public function extractVatNumber(?string $vatId): string
|
||||
{
|
||||
$normalized = $this->normalize($vatId);
|
||||
|
||||
if (in_array(substr($normalized, 0, 2), self::EU_COUNTRY_CODES, true)) {
|
||||
return substr($normalized, 2);
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grobe Formatprüfung: EU-Länderpräfix + 2-13 Zeichen Nummernteil.
|
||||
*/
|
||||
public function isPlausibleFormat(?string $vatId): bool
|
||||
{
|
||||
$normalized = $this->normalize($vatId);
|
||||
|
||||
return (bool) preg_match('/^[A-Z]{2}[A-Z0-9+*]{2,13}$/', $normalized)
|
||||
&& in_array(substr($normalized, 0, 2), self::EU_COUNTRY_CODES, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nur ausländische EU-USt-IDs qualifizieren für steuerfreie igL / Reverse-Charge.
|
||||
* Deutsche USt-IDs (DE...) werden grundsätzlich abgelehnt.
|
||||
*/
|
||||
public function qualifiesForReverseCharge(?string $vatId): bool
|
||||
{
|
||||
return $this->isPlausibleFormat($vatId) && ! $this->isGermanVatId($vatId);
|
||||
}
|
||||
|
||||
public function isGermanVatId(?string $vatId): bool
|
||||
{
|
||||
return str_starts_with($this->normalize($vatId), 'DE');
|
||||
}
|
||||
|
||||
/**
|
||||
* Live-Prüfung gegen VIES.
|
||||
*
|
||||
* @return string self::CHECK_VALID | self::CHECK_INVALID | self::CHECK_UNAVAILABLE
|
||||
*/
|
||||
public function checkVies(?string $vatId, ?string $fallbackCountryCode = null): string
|
||||
{
|
||||
if (! $this->isPlausibleFormat($vatId)) {
|
||||
return self::CHECK_INVALID;
|
||||
}
|
||||
|
||||
$countryCode = $this->extractCountryCode($vatId, $fallbackCountryCode);
|
||||
$vatNumber = $this->extractVatNumber($vatId);
|
||||
|
||||
if (! $countryCode || $vatNumber === '') {
|
||||
return self::CHECK_INVALID;
|
||||
}
|
||||
|
||||
if (! config('services.vies.enabled', true)) {
|
||||
return self::CHECK_UNAVAILABLE;
|
||||
}
|
||||
|
||||
$timeout = (int) config('services.vies.timeout', 10);
|
||||
|
||||
try {
|
||||
$client = new \SoapClient(self::VIES_WSDL, [
|
||||
'cache_wsdl' => WSDL_CACHE_NONE,
|
||||
'connection_timeout' => $timeout,
|
||||
'exceptions' => true,
|
||||
'stream_context' => stream_context_create([
|
||||
'http' => ['timeout' => $timeout],
|
||||
]),
|
||||
]);
|
||||
|
||||
$result = $client->checkVat([
|
||||
'countryCode' => $countryCode,
|
||||
'vatNumber' => $vatNumber,
|
||||
]);
|
||||
|
||||
return $result->valid ? self::CHECK_VALID : self::CHECK_INVALID;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('VIES-Prüfung nicht erreichbar', [
|
||||
'vat_id' => $this->normalize($vatId),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return self::CHECK_UNAVAILABLE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* VIES-Prüfung mit Cache (24h). CHECK_UNAVAILABLE wird nur kurz gecacht (10 Min.),
|
||||
* damit ein VIES-Ausfall den Checkout nicht bei jedem Aufruf blockiert.
|
||||
*/
|
||||
public function checkViesCached(?string $vatId, ?string $fallbackCountryCode = null): string
|
||||
{
|
||||
if (! $this->isPlausibleFormat($vatId)) {
|
||||
return self::CHECK_INVALID;
|
||||
}
|
||||
|
||||
$cacheKey = 'vies-check:'.$this->normalize($vatId);
|
||||
|
||||
$cached = Cache::get($cacheKey);
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$result = $this->checkVies($vatId, $fallbackCountryCode);
|
||||
|
||||
$ttl = $result === self::CHECK_UNAVAILABLE ? now()->addMinutes(10) : now()->addHours(24);
|
||||
Cache::put($cacheKey, $result, $ttl);
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
|
@ -320,7 +320,7 @@ class Yard extends Cart
|
|||
$this->shipping_price = $price;
|
||||
$this->shipping_tax_rate = $shipping_price->tax_rate;
|
||||
$this->shipping_price_net = round($price / ((100 + $shipping_price->tax_rate) / 100), 2);
|
||||
$this->shipping_tax = round($price / (100 + $shipping_price->tax_rate) * 100, 2);
|
||||
$this->shipping_tax = round($price - $this->shipping_price_net, 2);
|
||||
|
||||
$this->putYardExtra('num_comp', $this->num_comp);
|
||||
$this->putYardExtra('shipping_price', $this->shipping_price);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\User;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|
|
@ -30,11 +32,16 @@ return [
|
|||
],
|
||||
|
||||
'stripe' => [
|
||||
'model' => App\User::class,
|
||||
'model' => User::class,
|
||||
'key' => env('STRIPE_KEY'),
|
||||
'secret' => env('STRIPE_SECRET'),
|
||||
],
|
||||
|
||||
'vies' => [
|
||||
'enabled' => env('VIES_ENABLED', true),
|
||||
'timeout' => env('VIES_TIMEOUT', 10),
|
||||
],
|
||||
|
||||
'openai' => [
|
||||
'api_key' => env('OPENAI_API_KEY'),
|
||||
'url' => env('OPENAI_API_URL', 'https://api.openai.com/v1/chat/completions'),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('user_abos', function (Blueprint $table) {
|
||||
$table->date('last_pause_date')->nullable()->after('cancel_date');
|
||||
$table->unsignedInteger('pause_count')->default(0)->after('cancel_date');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('user_abos', function (Blueprint $table) {
|
||||
$table->dropColumn(['last_pause_date', 'pause_count']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('user_abo_status_histories', function (Blueprint $table) {
|
||||
$table->increments('id');
|
||||
$table->unsignedInteger('user_abo_id');
|
||||
$table->string('action', 32);
|
||||
$table->string('view', 16)->nullable();
|
||||
$table->unsignedInteger('actor_user_id')->nullable();
|
||||
$table->date('previous_next_date')->nullable();
|
||||
$table->date('new_next_date')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['user_abo_id', 'created_at']);
|
||||
$table->foreign('user_abo_id')->references('id')->on('user_abos')->onDelete('cascade');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('user_abo_status_histories');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Datenbereinigung (Feedback 2026-07-24, Punkt 2b): Per Admin-Löschung
|
||||
* soft-deletete Abos behielten active=1/Status 2-3 und verfälschten damit
|
||||
* Ist/Soll- und Incentive-Vergleiche. Bestehende Leichen deaktivieren;
|
||||
* Neufälle verhindert der deleting-Hook am UserAbo-Model.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::table('user_abos')
|
||||
->whereNotNull('deleted_at')
|
||||
->where('active', true)
|
||||
->update(['active' => false]);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Nicht umkehrbar: welche gelöschten Abos zuvor active=1 trugen, ist
|
||||
// nach der Bereinigung nicht mehr feststellbar (bewusster Daten-Fix).
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
Rechnung;Betrag Test;Betrag gebucht;Delta_1;Delta;Fehler;Kategorie;Bemerkung
|
||||
GS202536995;56,7;67,47;-10,77;-10,77;Test;Testdaten fehlerhaft: Betrag;gebuchte Gutschrift stimmt mit Beleg überein
|
||||
GS202538597;12,5;14,88;-2,38;-2,38;Test;Testdaten fehlerhaft: Betrag;gebuchte Gutschrift stimmt mit Beleg überein
|
||||
|
15
dev/2026-07-17-steuern/Abgleich 08._09.2025-Konto-8125.csv
Normal file
15
dev/2026-07-17-steuern/Abgleich 08._09.2025-Konto-8125.csv
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
Rechnung;Betrag Test;Betrag gebucht;Delta_1;Delta;Fehler;Kategorie;Bemerkung
|
||||
202536858;#NV;-207,87;207,87;Fehler;Test;Testdaten unvollständig ;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht
|
||||
202536910;#NV;-131,43;131,43;Fehler;Test;Testdaten unvollständig ;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht
|
||||
202537173;#NV;-139,13;139,13;Fehler;Test;Testdaten unvollständig ;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht
|
||||
202538144;#NV;-131,83;131,83;Fehler;Test;Testdaten unvollständig ;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht
|
||||
202536841;-111,18;#NV;-111,18;Fehler;Test;Testdaten fehlerhaft: Logik;Rechnung korrekterweise auf 8400 verbucht nicht 8125
|
||||
202538398;-180,09;#NV;-180,09;Fehler;Test;Testdaten fehlerhaft: Logik;Rechnung korrekterweise auf 8400 verbucht nicht 8125
|
||||
202536848;#NV;-277,92;277,92;Fehler;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein
|
||||
202537290;#NV;-269,46;269,46;Fehler;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein
|
||||
202537430;#NV;-78,18;78,18;Fehler;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein
|
||||
202537832;#NV;-153,93;153,93;Fehler;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein
|
||||
202538011;#NV;-308,35;308,35;Fehler;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein
|
||||
202538445;#NV;-270,6;270,6;Fehler;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein
|
||||
202538446;#NV;-427,16;427,16;Fehler;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein
|
||||
202539399;#NV;-295,12;295,12;Fehler;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
Rechnung;Betrag Test;Betrag gebucht;Delta_1;Delta;Fehler;Kategorie;Bemerkung
|
||||
202535876;#NV;46,64;-46,64;Fehler;Test;Testdaten unvollständig;Für Rechnung aus Juli wurde eine Gutschrift im September erfasst
|
||||
202537567;#NV;-75,42;75,42;Fehler;Test;Testdaten unvollständig;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht
|
||||
202538398;#NV;-9,75;9,75;Fehler;Test;Testdaten unvollständig;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht
|
||||
202538182;-64,24;-46,64;-17,6;-17,6;Test;Testdaten fehlerhaft: Betrag;Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht
|
||||
202537572;-75,42;#NV;-75,42;Fehler;Test;Test fehlerhaft: Logik;Rechnung korrekterweise auf 8400 (gebucht), da Stl. Kennung nicht vorhanden, Betrag nicht korrekt
|
||||
202539011;-61,89;#NV;-61,89;Fehler;;Buchungsdaten unvollständig;Rechnung liegt uns nicht vor
|
||||
|
37
dev/2026-07-17-steuern/Abgleich 08._09.2025-Konto8400.csv
Normal file
37
dev/2026-07-17-steuern/Abgleich 08._09.2025-Konto8400.csv
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
Rechnung;Betrag Test;Betrag gebucht;Delta_1;Delta;Fehler;Kategorie;Bemerkung
|
||||
202506145;#NV;-265,8;265,8;Fehler;Test;Testdaten unvollständig;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht
|
||||
202506147;#NV;-115,5;115,5;Fehler;Test;Testdaten unvollständig;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht
|
||||
202536821;#NV;-444,54;444,54;Fehler;Test;Testdaten unvollständig;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht
|
||||
202536841;#NV;-93,43;93,43;Fehler;Test;Testdaten unvollständig;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht
|
||||
202537460;#NV;-221,09;221,09;Fehler;Test;Testdaten unvollständig;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht
|
||||
202537543;#NV;-791,8;791,8;Fehler;Test;Testdaten unvollständig;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht
|
||||
202538398;#NV;-142,57;142,57;Fehler;Test;Testdaten unvollständig;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht
|
||||
202538744;#NV;-87,9;87,9;Fehler;Test;Testdaten unvollständig;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht
|
||||
202536978;-95,29;0;-95,29;-95,29;Test;Testdaten fehlerhaft: Storno;Rg. Wurde storniert wegen Doppeltbestellung
|
||||
202537144;-146,97;0;-146,97;-146,97;Test;Testdaten fehlerhaft: Storno;Rg. Wurde storniert wegen Falschbestellung
|
||||
202537513;-101,68;-22,27;-79,41;-79,41;Test;Testdaten fehlerhaft: Storno;Rg. Wurde teilweise storniert
|
||||
202539501;-93,43;0;-93,43;-93,43;Test;Testdaten fehlerhaft: Storno;Rg. Wurde storniert wegen Generalumkehr
|
||||
202536858;-174,68;#NV;-174,68;Fehler;Test;Testdaten fehlerhaft: Logik;Rechnung korrekterweise auf 8125 verbucht nicht auf 8400, Test: Betrag nicht korrekt (207,87€)
|
||||
202536910;-110,45;#NV;-110,45;Fehler;Test;Testdaten fehlerhaft: Logik;Rechnung korrekterweise auf 8125 verbucht nicht auf 8400, Test: Betrag nicht korrekt (131,43€)
|
||||
202537173;-116,92;#NV;-116,92;Fehler;Test;Testdaten fehlerhaft: Logik;Rechnung korrekterweise auf 8125 verbucht nicht auf 8400, Test: Betrag nicht korrekt (139,13€)
|
||||
202537430;-65,7;#NV;-65,7;Fehler;Test;Testdaten fehlerhaft: Logik;Rechnung korrekterweise auf 8125 verbucht nicht auf 8400, Test: Betrag nicht korrekt (78,14€)
|
||||
202538144;-110,78;#NV;-110,78;Fehler;Test;Testdaten fehlerhaft: Logik;Rechnung korrekterweise auf 8125 verbucht nicht auf 8400, Test: Betrag nicht korrekt (131,83€)
|
||||
202536848;-233,55;#NV;-233,55;Fehler;Test;Testdaten fehlerhaft: Logik;Rechnung korrekterweise auf 8125 gebucht nicht 8400
|
||||
202537290;-226,44;#NV;-226,44;Fehler;Test;Testdaten fehlerhaft: Logik;Rechnung korrekterweise auf 8125 gebucht nicht 8400
|
||||
202537832;-129,35;#NV;-129,35;Fehler;Test;Testdaten fehlerhaft: Logik;Rechnung korrekterweise auf 8125 gebucht nicht 8400
|
||||
202538445;-227,39;#NV;-227,39;Fehler;Test;Testdaten fehlerhaft: Logik;Rechnung korrekterweise auf 8125 gebucht nicht 8400
|
||||
202538446;-358,96;#NV;-358,96;Fehler;Test;Testdaten fehlerhaft: Logik;Rechnung korrekterweise auf 8125 gebucht nicht 8400
|
||||
202538774;-87,9;#NV;-87,9;Fehler;Test;Testdaten fehlerhaft: Logik;Rechnung korrekterweise auf 8125 gebucht nicht 8400 (Beleg nicht angehängt)
|
||||
202539011;-239,65;#NV;-239,65;Fehler;Test;Testdaten fehlerhaft: Logik;Rechnung korrekterweise auf 8125 gebucht nicht 8400 (Beleg nicht angehängt)
|
||||
202539399;-248;#NV;-248;Fehler;Test;Testdaten fehlerhaft: Logik;Rechnung korrekterweise auf 8125 gebucht nicht 8400
|
||||
202537251;#NV;-42,3;42,3;Fehler;Test;Testdaten fehlerhaft: Logik;von 8125 auf 8400 gebucht, obwohl spanische USt-IdNr. Vorhanden, diese ist ungültig
|
||||
202536817;-230,28;-1434,71;1204,43;1204,43;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,01 EUR)
|
||||
202536968;-167,67;-1044,69;877,02;877,02;Test;Testdaten fehlerhaft: Betrag;Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht
|
||||
202537208;-122,52;-101,68;-20,84;-20,84;Test;Testdaten fehlerhaft: Betrag;Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht
|
||||
202537532;-172,55;-1075,04;902,49;902,49;Test;Testdaten fehlerhaft: Betrag;Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht
|
||||
202537567;-18,32;-86,72;68,4;68,4;Test;Testdaten fehlerhaft: Betrag;Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht
|
||||
202537572;-177,06;-90,34;-86,72;-86,72;Test;Testdaten fehlerhaft: Betrag;Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht
|
||||
202537808;-86,22;-86,13;-0,09;-0,09;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,09 EUR)
|
||||
202538011;-259,12;-239,65;-19,47;-19,47;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,01 EUR)
|
||||
202538182;-231,04;-134,54;-96,5;-96,5;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,01 EUR)
|
||||
202538332;-40,84;-4,12;-36,72;-36,72;Test;Testdaten fehlerhaft: Betrag;Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht
|
||||
|
135
dev/2026-07-17-steuern/INFO-Steuerberater.md
Normal file
135
dev/2026-07-17-steuern/INFO-Steuerberater.md
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
# Rückmeldung zum Abgleich 08+09/2025 (DATEV-Export MIVITA)
|
||||
|
||||
**Datum:** 17.07.2026
|
||||
**Bezug:** Abgleich-Listen zu den Konten 4764, 8125, 8300 und 8400 für August/September 2025
|
||||
|
||||
Vielen Dank für den detaillierten Abgleich. Alle gemeldeten Abweichungen wurden im System
|
||||
nachvollzogen. Es handelte sich um **fünf technische Ursachen im Export bzw. in der
|
||||
Datenhaltung** — die Buchungen im Steuerbüro waren in nahezu allen Fällen korrekt, die
|
||||
Testdaten (unser DATEV-Export) fehlerhaft. Die Ursachen sind behoben; die Exporte für
|
||||
08/2025 und 09/2025 werden neu erstellt und ersetzt.
|
||||
|
||||
---
|
||||
|
||||
## 1. Beträge über 1.000 € waren im Export drastisch zu niedrig
|
||||
|
||||
**Betraf:** 202536817, 202536968, 202537532 (Konto 8400)
|
||||
|
||||
Ein Formatierungsfehler beim Speichern der Steuer-/Nettobeträge (Tausendertrennzeichen)
|
||||
führte dazu, dass z. B. aus 1.434,70 € netto im Export 1,43 € wurden. Die Rechnungen
|
||||
selbst (PDF/Beleg) waren korrekt — nur der Exportbetrag war falsch.
|
||||
|
||||
**Jetzt:** Der Fehler ist an der Quelle und im Export behoben. Im neuen Export stimmen
|
||||
diese Positionen mit den Belegen überein.
|
||||
|
||||
---
|
||||
|
||||
## 2. Kontenzuordnung 8125 (steuerfreie igL) ↔ 8400/8300
|
||||
|
||||
**Betraf u. a.:** 202536848, 202536858, 202536910, 202537173, 202537290, 202538011,
|
||||
202538144, 202538445, 202538446, 202539399 (gehörten auf 8125) sowie
|
||||
202536841, 202537251, 202537460, 202537543, 202538398 (gehörten auf 8400/8300)
|
||||
|
||||
Zwei Ursachen:
|
||||
|
||||
1. Der Export ermittelte die Steuerbehandlung aus dem **heutigen** Kundenstammsatz statt
|
||||
aus der tatsächlichen Rechnungsstellung. Hatte ein Berater seine USt-ID nach dem Kauf
|
||||
wieder entfernt, buchte der Export die (korrekt steuerfrei ausgestellte) Rechnung
|
||||
fälschlich auf 8400 — und umgekehrt.
|
||||
2. Hinterlegte USt-IDs wurden **nicht validiert**: Auch deutsche USt-IDs, offensichtliche
|
||||
Platzhalter (z. B. „DE123456789") und formal ungültige Nummern führten zur
|
||||
igL-Buchung auf 8125.
|
||||
|
||||
**Jetzt:**
|
||||
- Maßgeblich für die Kontenzuordnung ist, **wie die Rechnung tatsächlich ausgestellt
|
||||
wurde**: ohne USt verkaufte EU-Lieferungen → 8125 (BU 1); mit USt berechnete
|
||||
Lieferungen → 8400/8300 nach Steuersatz. Der Export entspricht damit dem Beleg.
|
||||
- **Deutsche USt-IDs werden systemweit nicht mehr als Grundlage für steuerfreie
|
||||
Lieferungen akzeptiert** (weder bei der Eingabe noch im Export).
|
||||
- Die USt-ID wird **bei jeder Bestellung erneut live über die EU-Datenbank (VIES)
|
||||
geprüft**. Ist sie ungültig oder abgelaufen, wird die Bestellung automatisch **mit
|
||||
Umsatzsteuer** berechnet und der Kunde darauf hingewiesen. Der Fall „einmal validiert,
|
||||
dauerhaft steuerfrei trotz abgelaufener ID" (z. B. ESY9284953M, validiert 2023) kann
|
||||
damit nicht mehr auftreten.
|
||||
|
||||
---
|
||||
|
||||
## 3. Doppelt vergebene Rechnungsnummern
|
||||
|
||||
**Betraf:** 202537208, 202537572, 202538182, 202538332
|
||||
|
||||
Diese vier Nummern wurden im Zeitraum tatsächlich **jeweils für zwei verschiedene
|
||||
Rechnungen** vergeben (technischer Fehler bei zeitgleicher Rechnungserstellung). Im
|
||||
Export erschienen deshalb unter einer Belegnummer die Positionen beider Rechnungen —
|
||||
daher die nicht zuordenbaren Beträge.
|
||||
|
||||
**Jetzt:** Die Nummernvergabe ist technisch gegen Doppelvergabe gesichert (für Rechnungen
|
||||
bereits seit längerem, jetzt auch für Gutschriften, die denselben Nummernkreis nutzen).
|
||||
Die vier Altfälle bleiben in den Daten bestehen; im neuen Export sind die betroffenen
|
||||
Positionen weiterhin unter derselben Nummer als getrennte Zeilen erkennbar. Falls für die
|
||||
Verbuchung eine Aufstellung der jeweils zwei Einzelrechnungen benötigt wird, liefern wir
|
||||
diese gern nach.
|
||||
|
||||
---
|
||||
|
||||
## 4. Gutschriften auf Konto 4764 (GS202536995, GS202538597)
|
||||
|
||||
Beide Provisionsgutschriften wurden **ohne Umsatzsteuer** ausgestellt (Empfänger nicht
|
||||
USt-pflichtig / Ausland). Der Export übergab sie jedoch mit dem Steuerschlüssel für 19 %,
|
||||
wodurch DATEV rechnerisch die USt herausrechnete (56,70 statt 67,47 bzw. 12,50 statt
|
||||
14,88).
|
||||
|
||||
**Jetzt:** Der Steuerschlüssel wird aus dem Steuerstatus **zum Zeitpunkt der
|
||||
Gutschriftserstellung** übernommen — also so, wie er auf dem Beleg ausgewiesen ist.
|
||||
|
||||
---
|
||||
|
||||
## 5. Stornierte Rechnungen aus 2025
|
||||
|
||||
**Betraf:** 202536978, 202537144, 202539501 (voll storniert), 202537513 (teilstorniert)
|
||||
|
||||
Diese Stornierungen wurden 2025 außerhalb des Systems abgewickelt und sind dort nicht
|
||||
als Storno erfasst; der Export enthielt daher die vollen Beträge. Systemseitige
|
||||
Stornorechnungen gibt es erst seit Anfang 2026 — seither erscheinen Stornos automatisch
|
||||
als eigene Belegzeilen im Export.
|
||||
|
||||
**Für die Altfälle:** Die Verbuchung im Steuerbüro (Betrag 0 bzw. Teilbetrag) ist
|
||||
korrekt; hier bitten wir, die Abweichung zum Export einmalig manuell zu berücksichtigen.
|
||||
|
||||
---
|
||||
|
||||
## 6. Belege außerhalb unseres Nummernkreises
|
||||
|
||||
**Betraf:** 202506145, 202506147 (ggf. weitere, inkl. einzelner Stornorechnungen)
|
||||
|
||||
Diese Rechnungen stammen **nicht aus dem Shopsystem**, sondern aus einem separaten
|
||||
Rechnungskreislauf des Unternehmens. Sie können und werden im DATEV-Export des Shops
|
||||
nicht auftauchen und sollten im Abgleich als „extern" gekennzeichnet werden.
|
||||
|
||||
---
|
||||
|
||||
## Einzelfälle
|
||||
|
||||
- **202539011** („Rechnung liegt uns nicht vor"): Die Rechnung existiert im System
|
||||
(Positionen 66,22 € zu 7 % und 285,18 € zu 19 %); das PDF wird nachgereicht.
|
||||
- **Cent-Differenzen (0,01–0,09 €**, z. B. 202537808, 202538011, 202538182):
|
||||
Rundungsdifferenzen bei der Netto-Aufteilung je Steuersatz; im neuen Export
|
||||
weitgehend bereinigt, Restdifferenzen im Centbereich können vereinzelt verbleiben.
|
||||
|
||||
---
|
||||
|
||||
## Zusammenfassung / nächste Schritte
|
||||
|
||||
| Thema | Status |
|
||||
| --- | --- |
|
||||
| Beträge > 1.000 € | behoben, neuer Export korrekt |
|
||||
| Kontenzuordnung 8125/8400/8300 | behoben, folgt jetzt dem Beleg |
|
||||
| USt-ID-Prüfung | ab sofort Live-Prüfung (VIES) bei jeder Bestellung, keine deutschen USt-IDs |
|
||||
| Doppelte Rechnungsnummern | Ursache behoben; 4 Altfälle dokumentiert |
|
||||
| Gutschriften 4764 | behoben, Steuerschlüssel laut Beleg |
|
||||
| Stornos 2025 | Altfälle: bitte manuell berücksichtigen; ab 2026 automatisch im Export |
|
||||
| Externe Belege | nicht Teil des Shop-Exports, als „extern" markieren |
|
||||
|
||||
Die **korrigierten DATEV-Exporte für 08/2025 und 09/2025** werden nach Einspielen der
|
||||
Änderungen neu generiert und bereitgestellt. Bei Rückfragen zu einzelnen Belegen liefern
|
||||
wir gern Detailauswertungen nach.
|
||||
181
dev/2026-07-17-steuern/UMSETZUNG-fixes.md
Normal file
181
dev/2026-07-17-steuern/UMSETZUNG-fixes.md
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
# Steuerberater-Abgleich 08+09/2025 — Analyse & Umsetzung der Fixes
|
||||
|
||||
**Datum:** 17.07.2026
|
||||
**Anlass:** Korrektur-CSVs des Steuerberaters (`Abgleich 08._09.2025-Konto-*.csv` in diesem Ordner) zum DATEV-Export aus `admin/payments/taxadvisor`.
|
||||
**Betroffene Exporte:** `datev_exports` #9 (08/2025) und #10 (09/2025), generiert am 08.05.2026.
|
||||
|
||||
---
|
||||
|
||||
## Lesehilfe zu den Abgleich-CSVs
|
||||
|
||||
Die Spalte **„Betrag Test"** des Steuerberaters ist **nicht** unser Exportbetrag, sondern der
|
||||
Saldo auf dem Erlöskonto **nach DATEV-Steuerautomatik**: Wir exportieren Bruttobeträge mit
|
||||
BU-Schlüssel (9 = 19 %, 8 = 7 %), DATEV rechnet die USt automatisch heraus.
|
||||
|
||||
> Beispiel: Export 277,92 € brutto (BU 9) → 233,55 € netto auf 8400 = „Betrag Test".
|
||||
|
||||
Mit diesem Schlüssel lassen sich fast alle gemeldeten Zeilen exakt nachvollziehen.
|
||||
|
||||
---
|
||||
|
||||
## Befund 1: Beträge ≥ 1.000 € massiv zu niedrig (kritisch)
|
||||
|
||||
**Gemeldete Fälle:** 202536817 (1.434,71 statt 230,28), 202536968 (1.044,69 statt 167,67), 202537532 (1.075,04 statt 172,55)
|
||||
|
||||
**Ursache:** `ShoppingOrder::makeTaxSplit()` speicherte `net_split`/`tax_split` mit
|
||||
`number_format($value, 2)` — also **mit Tausendertrennzeichen** (`"1,434.70"`).
|
||||
`DatevExportService::parseNumber()` machte daraus per `str_replace(',', '.')` den String
|
||||
`"1.434.70"` → `floatval()` = **1,43 €**. Jede Rechnung mit ≥ 1.000 € Netto pro Steuersatz
|
||||
war im Export um den Faktor ~1000 zu niedrig.
|
||||
|
||||
**Fix:**
|
||||
- `app/Services/DatevExportService.php` → `parseNumber()` erkennt jetzt US-Format
|
||||
(`1,434.70`), deutsches Format (`1.434,70`) und reine Tausendertrenner (`1,434`) korrekt.
|
||||
Nötig, weil die **Altdaten in der DB** das fehlerhafte Format behalten.
|
||||
- `app/Models/ShoppingOrder.php` → `makeTaxSplit()` und `makeHomepartyTaxSplit()` schreiben
|
||||
neu mit `number_format($value, 2, '.', '')` (ohne Tausendertrenner).
|
||||
|
||||
---
|
||||
|
||||
## Befund 2: Falsche Kontenzuordnung 8125 (igL) ↔ 8400/8300
|
||||
|
||||
**Gemeldete Fälle (Auswahl):** 202536848, 202536858, 202536910, 202537173, 202537290,
|
||||
202538011, 202538144, 202538445/446, 202539399 (gehörten auf 8125, standen auf 8400) —
|
||||
umgekehrt 202536841, 202537251, 202537460, 202537543, 202538398 (standen auf 8125, gehörten auf 8400/8300).
|
||||
|
||||
**Ursachen (mehrere):**
|
||||
|
||||
1. **Export nutzte den aktuellen Account-Zustand statt des Rechnungszustands.**
|
||||
Die betroffenen Berater (z. B. User 781, 850, 974) hatten ihre USt-ID nach dem Kauf
|
||||
wieder gelöscht (`reverse_charge_delete` leert alle Felder). Die Rechnungen wurden
|
||||
damals korrekt **ohne USt** (igL) ausgestellt, der Export fand aber keine USt-ID mehr
|
||||
und buchte auf 8400.
|
||||
2. **Keinerlei Validierung der USt-ID:** `hasValidUstid` prüfte nur „nicht leer".
|
||||
Dadurch lösten auch **deutsche** USt-IDs (202536841: `DE296797691`), offensichtliche
|
||||
Dummy-IDs (202538398: `DE123456789`) und formal ungültige Werte
|
||||
(202537460: `53618009` — gar kein USt-ID-Format) eine igL-Buchung auf 8125 aus.
|
||||
3. **Abgelaufene/ungültige IDs:** Die VIES-Prüfung lief nur **einmalig bei der Eingabe**
|
||||
(202537251: `ESY9284953M`, validiert 08/2023, laut StB inzwischen ungültig).
|
||||
|
||||
**Datenlage als Beleg:** In 08+09/2025 hat **jede** DE-Bestellung ein `tax_split`;
|
||||
EU-Bestellungen **ohne** `tax_split` sind exakt die steuerfrei verkauften (AT: 71, ES: 14).
|
||||
|
||||
**Fix:**
|
||||
- `app/Services/DatevExportService.php` → `determineRevenueAccount()`: Maßgeblich ist
|
||||
jetzt, **wie die Rechnung tatsächlich ausgestellt wurde**:
|
||||
- EU-Bestellung **ohne** `tax_split` (= ohne USt verkauft) → 8125 / BU 1 (igL) —
|
||||
unabhängig vom heutigen Account-Zustand.
|
||||
- EU-Bestellung **mit** `tax_split` (= mit USt berechnet) → 8400/8300 nach Steuersatz,
|
||||
nie 8125.
|
||||
- Drittland unverändert 8120 / BU 11.
|
||||
- `resolveEuUstid()` verwirft deutsche und formal unplausible USt-IDs (neuer zentraler
|
||||
`App\Services\VatIdService`). Fehlt bei einer igL-Zeile die USt-ID, erscheint wie bisher
|
||||
eine Validierungs-Warnung im Export (`validation_summary`).
|
||||
|
||||
---
|
||||
|
||||
## Befund 3: „Betrag fehlerhaft" durch doppelte Rechnungsnummern
|
||||
|
||||
**Gemeldete Fälle:** 202537208, 202537572, 202538182, 202538332 — exakt die vier Nummern,
|
||||
die in 08+09/2025 **doppelt vergeben** wurden (je zwei verschiedene Rechnungen/Bestellungen).
|
||||
Der StB sah unter einer Belegnummer die Summe zweier Rechnungen.
|
||||
|
||||
**Ursache:** Historisches Race bei der Nummernvergabe (Zähler-Setting `invoice-number`).
|
||||
Für **Rechnungen** ist das seit `Invoice::makeNextInvoiceNumber()` behoben
|
||||
(`DB::transaction` + `lockForUpdate`). **Gutschriften** nutzten aber denselben Zähler
|
||||
weiterhin **ungesichert**: `CreditRepository::create()` las die Nummer (plain read) und
|
||||
zählte erst viel später in `finishUserCredit()` hoch — dazwischen konnten parallele Läufe
|
||||
dieselbe Nummer ziehen. Folge in den Bestandsdaten: 38 Gutschriften teilen sich ihre
|
||||
laufende Nummer mit einer Rechnung.
|
||||
|
||||
**Fix:**
|
||||
- `app/Services/Credit.php` → `makeNextCreditNumber()` delegiert an
|
||||
`Invoice::makeNextInvoiceNumber()` (atomar, gelockt).
|
||||
- `app/Repositories/CreditRepository.php` → Nummer wird direkt bei der Erstellung atomar
|
||||
reserviert; das nachträgliche Hochzählen in `finishUserCredit()` entfällt.
|
||||
|
||||
Bestehende Duplikate in den Altdaten bleiben unverändert (nicht rückwirkend korrigierbar).
|
||||
|
||||
---
|
||||
|
||||
## Befund 4: Gutschriften auf 4764 mit falschem BU-Schlüssel
|
||||
|
||||
**Gemeldete Fälle:** GS202536995 (67,47 statt 56,70), GS202538597 (14,88 statt 12,50)
|
||||
|
||||
**Ursache:** Beide Gutschriften wurden mit `taxable = 3` (nicht USt-pflichtig / Ausland)
|
||||
ausgestellt — der Beleg weist **keine USt** aus. Der Export leitete den BU-Schlüssel aber
|
||||
vom **aktuellen** Account-Status ab und exportierte BU 9 (19 %) → DATEV rechnete fälschlich
|
||||
19 % aus dem steuerfreien Betrag heraus.
|
||||
|
||||
**Fix:**
|
||||
- `app/Services/DatevExportService.php` → `determineCommissionTaxStatus()` nutzt jetzt den
|
||||
`taxable`-Snapshot der Gutschrift (1 = BU 9, 2 = Kleinunternehmer BU 50, 3 = Ausland →
|
||||
Reverse Charge BU 94 bei EU). Nur Alt-Gutschriften ohne Snapshot (`taxable = 0`) fallen
|
||||
auf die Account-Logik zurück. Der Brutto-Aufschlag nutzt den in der Gutschrift
|
||||
gespeicherten `tax_rate`.
|
||||
|
||||
---
|
||||
|
||||
## Befund 5: USt-ID-Prüfung nur einmalig (Anforderung Kevin)
|
||||
|
||||
**Anforderung:** USt-ID bei **jeder Verwendung** prüfen (v. a. im Checkout); bei ungültiger
|
||||
ID Hinweis anzeigen und mit USt berechnen; **deutsche USt-IDs nie anerkennen**.
|
||||
|
||||
**Umsetzung — neuer zentraler Service `app/Services/VatIdService.php`:**
|
||||
- `normalize()`, `isPlausibleFormat()` (EU-Länderpräfix + Nummernteil),
|
||||
`qualifiesForReverseCharge()` (Format ok **und** nicht `DE...`),
|
||||
`checkVies()` (SOAP gegen `ec.europa.eu` VIES, Timeout konfigurierbar),
|
||||
`checkViesCached()` (24 h Cache; VIES-Ausfall nur 10 Min. gecacht).
|
||||
- **Eingabe im Account** (`UserRepository::reverse_charge_activate`):
|
||||
nutzt den Service; deutsche USt-IDs → eigene Fehlermeldung
|
||||
(`msg.VATID_german_not_allowed`, in de/en/es/fr ergänzt). SSL-Verifikation ist jetzt
|
||||
aktiv (vorher deaktiviert).
|
||||
- **Checkout** (`UserService::performUserTaxShippingCountry`): Wenn Reverse-Charge greifen
|
||||
würde, wird die USt-ID live (gecacht) geprüft:
|
||||
- **ungültig / DE / Format falsch** → Reverse-Charge-Status wird entzogen
|
||||
(`reverse_charge = 0`, `reverse_charge_valid = null`; die USt-ID selbst bleibt zur
|
||||
Neu-Validierung erhalten), Growl-Hinweis
|
||||
`msg.VATID_no_longer_valid_taxes_applied`, Bestellung läuft **mit USt** weiter.
|
||||
- **VIES nicht erreichbar** → gespeicherter Status gilt (Checkout wird nie blockiert),
|
||||
Log-Warnung.
|
||||
- **Konfiguration:** `services.vies.enabled` (`VIES_ENABLED`, Default `true`) und
|
||||
`services.vies.timeout` (`VIES_TIMEOUT`, Default 10 s). In `phpunit.xml` deaktiviert.
|
||||
|
||||
---
|
||||
|
||||
## Nicht im Code fixbar / offene Punkte
|
||||
|
||||
| Punkt | Status |
|
||||
| --- | --- |
|
||||
| **202506145 / 202506147**: existieren nicht in `user_invoices` — stammen aus einem **fremden Nummernkreis** des Kunden (dort werden teils eigene Rechnungen + Stornorechnungen erstellt) | Im Abgleich ignorieren und als „extern" markieren (Vorgabe Kevin) |
|
||||
| **Stornos 2025** (202536978, 202537144, 202537513 teilweise, 202539501): im System nicht als Storno erfasst — Stornorechnungen (`cancellation = 1`) existieren erst ab 01/2026 | Manuelle Abstimmung mit dem StB / Datenpflege |
|
||||
| **202535876**: Juli-Rechnung, Gutschrift im September — monatsübergreifende Korrekturen kennt der Export konzeptionell nicht | Offen, bei Bedarf separates Konzept |
|
||||
| **202539011**: „Rechnung liegt uns nicht vor" — Daten sind im System vorhanden (66,22 € + 285,18 €) | PDF an StB nachliefern |
|
||||
| **Exporte 08+09/2025 neu generieren**, sobald die Fixes deployed sind | To-do nach Merge |
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
- `tests/Unit/Services/DatevExportServiceTest.php` — erweitert: Tausendertrenner-Parsing,
|
||||
igL-Semantik (`soldTaxFree`), Ablehnung deutscher/unplausibler USt-IDs,
|
||||
`taxable`-Snapshot-Logik der Gutschriften.
|
||||
- `tests/Unit/Services/VatIdServiceTest.php` — **neu**: Normalisierung, Formatprüfung,
|
||||
DE-Ablehnung, VIES-Verhalten (deaktiviert/unplausibel).
|
||||
- `tests/Feature/CheckoutVatIdRevalidationTest.php` — **neu**: 6 Checkout-Szenarien
|
||||
(VIES ok / ungültig / ausgefallen, DE-ID, Format-Müll, Ländercode-Mismatch).
|
||||
|
||||
Alle 63 betroffenen Tests grün; `./vendor/bin/pint` sauber. Vorbestehende, unabhängige
|
||||
Testfehler (`InvoiceServiceTest`, `tests/Unit/BusinessPlan/*`, `makeUser()`-Kollision der
|
||||
Gesamtsuite) bestehen unverändert auch auf dem Stand vor diesen Änderungen.
|
||||
|
||||
## Geänderte Dateien
|
||||
|
||||
- `app/Services/DatevExportService.php` — parseNumber, Kontenlogik, USt-ID-Filter, Gutschrift-BU
|
||||
- `app/Services/VatIdService.php` — **neu**, zentrale USt-ID-Prüfung
|
||||
- `app/Services/UserService.php` — Live-Prüfung im Checkout + Entzug + Hinweis
|
||||
- `app/Services/Credit.php`, `app/Repositories/CreditRepository.php` — atomare Nummernvergabe
|
||||
- `app/Repositories/UserRepository.php` — VIES via Service, DE-Ablehnung bei Eingabe
|
||||
- `app/Models/ShoppingOrder.php` — number_format ohne Tausendertrenner
|
||||
- `config/services.php`, `phpunit.xml` — VIES-Konfiguration
|
||||
- `resources/lang/{de,en,es,fr}/msg.php` — neue Meldungen
|
||||
BIN
dev/2026-07-22/Statistiken kontrolliert.pdf
Normal file
BIN
dev/2026-07-22/Statistiken kontrolliert.pdf
Normal file
Binary file not shown.
233
dev/2026-07-22/entwicklungskonzept.md
Normal file
233
dev/2026-07-22/entwicklungskonzept.md
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
# Entwicklungskonzept — To-Dos vom 2026-07-22
|
||||
|
||||
Basis: `dev/2026-07-22/todos.md` (Kundenwünsche). Stand der Code-Analyse: 2026-07-22.
|
||||
Noch **kein Code geändert** — dieses Dokument ist die Planungsgrundlage.
|
||||
|
||||
Die To-Dos zerfallen in zwei Blöcke:
|
||||
|
||||
- **A — Abo-Verwaltung**: Pause-Funktion, Kündigung
|
||||
- **B — Statistiken & Dashboard**: Home-Dashboard, VIP-Statistik (`/user/backoffice/statistics`), Abo-Statistik
|
||||
|
||||
Die Führungskräfte-Statistik taucht in der To-Do-Liste dreimal auf (Z. 35–36, 62–64, 67–74) — inhaltlich ist das **eine** Anforderung (→ B7).
|
||||
|
||||
---
|
||||
|
||||
## Übersicht der Arbeitspakete
|
||||
|
||||
| # | Arbeitspaket | Ist-Stand | Aufwand | Offene Fragen |
|
||||
|---|---|---|---|---|
|
||||
| A1 | Abo-Pause (1 Monat, Regeln) | Nur Aktiv/Inaktiv-Toggle, keine Terminverschiebung | mittel | F1, F2 |
|
||||
| A2 | Abo-Kündigung prüfen/finalisieren | Weitgehend vorhanden (Status 4 + Bestätigung) | klein | F3, F4 |
|
||||
| B1 | Provision „Stand heute" (Home + Statistik-Übersicht) | Provision nur monatlich per Batch, admin-only | groß | F5, F6 |
|
||||
| B2 | Abo-Zahlen bei Monatswechsel aktuell | Snapshot friert Bestandszahlen ein | klein–mittel | F7 |
|
||||
| B3 | Neupartner-Liste: Sponsor + Kundenpunkte | Beides fehlt im Drilldown | klein | – |
|
||||
| B4 | 1000-Punkte-Shop: eigene Person zählt mit | Nur Team, Root-User ausgeschlossen | klein | – |
|
||||
| B5 | Top-10 Kundenabos (Anzahl) | Existiert nicht | klein–mittel | F8 |
|
||||
| B6 | Top-10 Kundenabos (Umsatz/Punkte) | Existiert nicht | klein–mittel | F8 |
|
||||
| B7 | Führungskräfte-Statistik: 4 Kennzahlen + Summe | Nur eigene Abos + eigene Kundenabos | mittel | F9 |
|
||||
| B8 | Abo-Gesamtumsatz in der Payline | Existiert nicht | mittel | F10 |
|
||||
| B9 | „Neue Abos" klickbar → wer sind sie | Nur `+N`-Badge, kein gefilterter Drilldown | klein | – |
|
||||
| B10 | Payline-Prognose zum 5./10./20./25. | Existiert nicht; Datenbasis vorhanden | mittel–groß | F11, F12 |
|
||||
|
||||
> **Update 2026-07-22:** Alle Fragen wurden vom Kunden beantwortet — Zusammenfassung im Abschnitt **„Entscheidungen"** am Ende. Die Entscheidungen sind in die Arbeitspakete unten eingearbeitet. Einzig F12 bleibt eine dokumentierte Annahme (Vorschlag umgesetzt, Kunde prüft nach Umsetzung — siehe B10).
|
||||
>
|
||||
> **Umsetzungsstand (2026-07-22):** ✅ **B3, B4, B9 und A2 sind umgesetzt** (inkl. Tests: `AboOneAboRuleTest`, Erweiterungen in `BackofficeStatisticsDrilldownTest`/`BackofficeStatisticsAccessTest`). Zu A2: Die Ein-Abo-Prüfungen (`AboHelper::userHasAbo/memberHasAbo/hasAboByEmail`) blockierten tatsächlich auch nach Kündigung (`status >= 1` schloss Status 4/5 ein) — behoben; `abo-min-duration` hat jetzt Code-Default 6 (`AboHelper::DEFAULT_ABO_MIN_DURATION`). Offen aus diesem Paket: Produktions-Setting `abo-min-duration` auf 6 prüfen.
|
||||
>
|
||||
> ✅ **B1 ist umgesetzt** (Tests: `LiveCommissionServiceTest`) — **damit sind ALLE Arbeitspakete dieses Konzepts umgesetzt.** Neuer `App\Services\BusinessPlan\LiveCommissionService`: Der bestehende Live-`TreeCalcBot`-Lauf berechnet entgegen der ursprünglichen Analyse bereits alle drei Provisionsbestandteile (`commission_shop_sales` in `makeUser()`, `commission_pp_total`/`commission_growth_total` in `calcQualPP()`) — es brauchte nur einen dünnen Service mit 6-h-Cache (F5). Liegt für den Monat ein `UserBusiness`-Datensatz vor (Monats-Batch gelaufen), gilt dieser als final (kein Live-Badge). Anzeige: Kachel „Provision (Stand heute)" auf dem Home-Dashboard für **jeden Berater** (F6) im Umsatz-Cluster (Gesamtwert in €, Live-Badge) + KPI-Card auf der VIP-Statistik-Übersicht mit Aufschlüsselung Payline/Shop/WB.
|
||||
>
|
||||
> ✅ **B8 + B10 sind umgesetzt** (Tests: `PaylineForecastServiceTest`): Neuer `App\Services\Abo\PaylineForecastService`. Payline = die ersten N Ebenen der Downline, N kommt live aus dem Provisionslevel (`TreeCalcBot`/`BusinessUserItem::getQualLevelPaylines()`, kein Bot-Umbau nötig); Kontext (N + Mitglieder-IDs) 6 h gecacht. **B8**: Karte „Abo-Umsatz in der Payline" auf der Team-Abo-Seite mit **Soll** (Abo-Punkte pro Zyklus, Berater- + Kundenabos der Payline) und **Ist** (im Monat abgerechnete Abo-Punkte, neues SQL-Fragment `SalesVolumePoints::aboPoints()`). **B10**: vier Prognose-Kacheln auf dem Home-Dashboard (nur laufender Monat): Basis = Kundenumsatz + Payline-Punkte heute, plus kumuliert die bis zum 5./10./20./25. noch offenen Abo-Punkte (`next_date` > heute); vergangene Termine ausgegraut. **F12 wie vorgeschlagen umgesetzt**: eigene Abos + eigene Kundenabos zählen mit — Schalter `PaylineForecastService::INCLUDE_OWN_ABOS` (Default true), ohne Umbau umkehrbar. → **Rückfrage an den Kunden nach Livegang:** Soll das so bleiben oder sollen nur die Payline-Abos zählen?
|
||||
>
|
||||
> ✅ **A1 ist umgesetzt** (Tests: `AboPauseTest`): Button „1 Monat pausieren" im Abo-Einstellungs-Modal (Berater + Kunden-Portal, nicht Admin-View), verschiebt `next_date` um genau einen Zyklus auf den gleichen Liefertag des Folgemonats, Abo bleibt aktiv (Status 2). Regeln serverseitig in `AboRepository::pauseOneMonth()`: 3-Tage-Sperre vor Ausführung, Mindestlaufzeit (`abo-min-duration`, wie Kündigung), max. 1 Pause pro X Monate rollierend über **neues Setting `abo-pause-interval-months`** (Code-Default 6, `AboHelper::DEFAULT_PAUSE_INTERVAL_MONTHS`). Neue Felder `last_pause_date`/`pause_count` an `user_abos` + neue Tabelle **`user_abo_status_histories`** — protokolliert jetzt Pause, Kündigung, Deaktivierung und Reaktivierung (wer/wann/View + Terminverschiebung). UI zeigt bei gesperrter Pause den Hinweis „wieder möglich ab …". Sprachkeys in de/en/es/fr ergänzt (du-Form).
|
||||
>
|
||||
> ✅ **B2, B5/B6 und B7 sind umgesetzt** (Tests in `BackofficeStatisticsListsTest`): Führungskräfte-Liste mit Teamabos/Team-Kundenabos je Führungskraft (Abschneiden an der nächsten Führungskraft, additive Summen) + Gesamt-Spalte; zwei Top-10-Karten „Kundenabos (Anzahl)" und „Kundenabos (Umsatz)" mit Soll (Abo-Punkte-Bestand) und Ist (abgerechnete Kundenabo-Punkte des Monats); Snapshot-Monate überlagern die Abo-Bestandszahlen (Linien, Summen, Führungskräfte, Top 10) jetzt live — Badge „Abo-Bestände tagesaktuell" in der Übersicht, Monats-Punkte/-Umsätze und „+N neu" bleiben eingefroren. Noch offen: A1 (Pause), dann B8+B10 und B1.
|
||||
|
||||
---
|
||||
|
||||
## Block A — Abo-Verwaltung
|
||||
|
||||
### A1 — Pause-Funktion (max. 1× pro 6 Monate, verschiebt um 1 Monat)
|
||||
|
||||
**Kundenwunsch:** Pausieren erst nach 6 Monaten möglich, maximal 1× in 6 Monaten, Pause = 1 Monat, die Ausführung verschiebt sich um einen Monat. Button in den Abo-Einstellungen.
|
||||
|
||||
**Ist-Stand:**
|
||||
- Es gibt heute keine echte Pause. Der Toggle „aktiv" in den Abo-Einstellungen setzt Status 2 ↔ 6 (`AboRepository::updateStatus()`, [AboRepository.php:102-127](app/Repositories/AboRepository.php#L102-L127)). Dabei wird `next_date` **nicht verschoben** — das Abo steht einfach still, bis es jemand wieder aktiviert.
|
||||
- Sperrfrist existiert: 3 Tage vor Ausführung keine Pause/Kündigung (Setting `abo-lock-days-pause-cancel`).
|
||||
- Es gibt **keine Historie** für Pause-Aktionen — man kann heute nicht feststellen, wann/wie oft ein Abo pausiert wurde.
|
||||
|
||||
**Konzept:**
|
||||
1. **Neuer Button „1 Monat pausieren"** im Abo-Einstellungs-Modal ([modal_abo_update.blade.php](resources/views/admin/abo/modal_abo_update.blade.php)) für Berater- und Portal-View (mit Bestätigungsabfrage, analog Kündigen-Button).
|
||||
2. **Wirkung:** `next_date` wird um genau einen Ausführungs-Zyklus verschoben (nächster `abo_interval`-Tag im Folgemonat via `AboHelper::setNextDate()`). Das Abo bleibt aktiv (Status 2) — kein Status-Wechsel nötig, dadurch bleiben alle Statistiken konsistent.
|
||||
3. **Neue Felder an `user_abos`** (Migration): `last_pause_date` (Datum der letzten Pause) und `pause_count` — oder alternativ Pause-Einträge in einer kleinen Historie (siehe Punkt 5).
|
||||
4. **Regeln (serverseitig in `AboHelper`/`AboRepository`, UI blendet Button entsprechend aus):**
|
||||
- Erlaubt erst ab 6 **bezahlten Lieferungen** (entschieden, F1) — gleiche Basis wie Kündigung: `getCountPaidOrders()` gegen das Setting `abo-min-duration`; vorhandene Mechanik `canCancelAbo()` wird zu `canPauseAbo()` gespiegelt.
|
||||
- Maximal 1 Pause pro X Monate, **rollierend** (entschieden, F2): `last_pause_date` muss länger als X Monate zurückliegen. X kommt aus einem **neuen Admin-Setting `abo-pause-interval-months`** (Default 6, änderbar — vom Kunden explizit gewünscht).
|
||||
- Bestehende 3-Tage-Sperre vor Ausführung gilt auch für die Pause.
|
||||
5. **Historie:** Pause (und idealerweise auch Kündigung, s. A2) wird protokolliert — Vorschlag: kleine Tabelle `user_abo_status_histories` (abo_id, action, actor_user_id, created_at) oder Erweiterung der bestehenden `AboItemHistoryService`-Mechanik um Status-Aktionen. Ohne Log ist die „1× in 6 Monaten"-Regel nicht sauber durchsetzbar und nicht nachvollziehbar.
|
||||
6. **Selfservice-Regel beachten:** wie bei den Lock-Settings gilt die Prüfung auf Basis des Consultant-Views, kein Admin-Bypass im Selfservice-Bereich (Admin-Bereich darf weiterhin frei agieren).
|
||||
|
||||
**Abgrenzung:** Der bestehende Aktiv/Inaktiv-Toggle bleibt unverändert bestehen (Admin-Werkzeug); die neue Pause ist eine separate, regelgebundene Funktion.
|
||||
|
||||
### A2 — Kündigung nach 6. Lieferung („bitte noch mal genau prüfen")
|
||||
|
||||
**Kundenwunsch:** Kündigung nach der 6. Lieferung, monatlich, über Berater oder Kunden-Login. Button „Abo jetzt kündigen" + Rückfrage, dann Entfernung (Softdelete).
|
||||
|
||||
**Ist-Stand — weitgehend vorhanden:**
|
||||
- Kündigen-Button mit `confirm()`-Rückfrage existiert im Abo-Einstellungs-Modal für Berater- **und** Portal-View ([modal_abo_update.blade.php:110-116](resources/views/admin/abo/modal_abo_update.blade.php#L110-L116)).
|
||||
- Mindestlaufzeit wird über `AboHelper::canCancelAbo()` gegen das Setting **`abo-min-duration`** geprüft (Zählung: bezahlte + extern erledigte Lieferungen). Vorher wird der Button gar nicht angezeigt, stattdessen ein Hinweis mit Rest-Lieferungen.
|
||||
- Kündigung setzt `status = 4` (storniert), `active = false`, `cancel_date = now()` — **kein Softdelete**. 3-Tage-Sperre vor Ausführung greift.
|
||||
|
||||
**Prüf-/Restpunkte:**
|
||||
1. **Setting-Wert kontrollieren:** `abo-min-duration` hat **keinen Code-Default und keinen Seeder** — fehlt der DB-Eintrag, ist die Mindestlaufzeit faktisch 0. In Produktion prüfen, dass das Setting auf `6` steht, zusätzlich Default `6` im Code hinterlegen. Kündigung ab der 6. **bezahlten** Lieferung ist bestätigt (F4) — bei rechtzeitiger Kündigung findet die 7. Lieferung nicht mehr statt.
|
||||
2. **Kein Softdelete, Status-Modell bestätigt (F3):** Beim regulären Kündigen bleibt es bei `status = 4` — das Abo bleibt für Statistik/Abrechnung nachvollziehbar und verschwindet aus den aktiven Listen.
|
||||
3. **Neues Abo nach Kündigung darf nicht blockiert werden (F3, wichtig):** Es gilt „immer nur EIN Abo pro Kunde". Die Prüfung, die ein zweites Abo verhindert, muss gekündigte/beendete Abos (Status 4/5, soft-deleted) **ignorieren** — nach einer Kündigung muss sofort ein neues Abo angelegt werden können. Ist-Verhalten der Ein-Abo-Prüfung analysieren, ggf. fixen, mit Test absichern.
|
||||
4. **Kündigungs-Historie** analog A1 (wer hat wann gekündigt — Berater oder Kunde) mitloggen.
|
||||
5. Kleiner UI-Review: Button-Beschriftung/Platzierung ggf. an Kundenwunsch anpassen („Abo jetzt kündigen").
|
||||
|
||||
---
|
||||
|
||||
## Block B — Statistiken & Dashboard
|
||||
|
||||
### B1 — Provision „Stand heute" (Home-Dashboard + Statistik-Übersicht)
|
||||
|
||||
**Kundenwunsch:** Auf dem Dashboard (und auf der Übersichtsliste, Z. 46) die aktuelle Provision Stand heute anzeigen.
|
||||
|
||||
**Ist-Stand:**
|
||||
- Provision wird ausschließlich per Batch `business:store-optimized` nach Monatsende berechnet und in `UserBusiness` gespeichert (`commission_pp_total`, `commission_shop_sales`, `commission_growth_total`). Anzeige heute nur im Admin-Bereich.
|
||||
- Live läuft im HomeController bereits ein `TreeCalcBot` — aber nur für **Payline-Punkte**, nicht für Provision ([HomeController.php:169-183](app/Http/Controllers/HomeController.php#L169-L183), Cache 6 h).
|
||||
|
||||
**Konzept:**
|
||||
1. Den vorhandenen Live-`TreeCalcBot`-Lauf so erweitern, dass zusätzlich zu `payline_points` auch die Provisions-Kennzahlen für den User berechnet werden (die Rechenlogik existiert in `TreeCalcBot`/`BusinessStoreOptimized`, muss aber für einen Einzel-User-Livelauf extrahiert werden).
|
||||
2. Ergebnis im selben 6-h-Cache mitführen (`home_payline_*` → erweitern), damit kein zweiter Baum-Lauf nötig ist.
|
||||
3. Anzeige: neue Kachel „Provision (Stand heute)" auf dem Home-Dashboard (`_statistics.blade.php`) mit „Live"-Badge + Hinweis „vorläufig, final nach Monatsabschluss"; gleiche Kennzahl als KPI-Card auf der VIP-Statistik-Übersicht.
|
||||
4. Für abgeschlossene Monate: fertigen Wert aus `UserBusiness` anzeigen (kein Live-Lauf).
|
||||
|
||||
**Entschieden (F5/F6):** Die Kennzahl enthält **alle Provisionsbestandteile** (Payline/Team + Shop + Wachstum), Aktualisierung alle ~6 h reicht, und sie wird **jedem Berater** auf dem Home-Dashboard angezeigt (nicht nur VIP).
|
||||
|
||||
**Risiko/Hinweis:** Die Live-Provision ist rechnerisch immer „vorläufig" (Ränge/Qualifikationen können sich bis Monatsende ändern). Das muss im UI klar gekennzeichnet sein. Aufwand ist der größte im Statistik-Block, weil Provisionslogik bisher nur im Monats-Batch für den Gesamtbaum läuft. Da die Kachel für **alle** Berater kommt, ist die Performance des Live-Laufs (6-h-Cache pro User, Berechnung beim ersten Dashboard-Aufruf) besonders zu beachten.
|
||||
|
||||
### B2 — Abo-Zahlen aktuell halten beim Monatswechsel
|
||||
|
||||
**Befund (Kundenbeobachtung bestätigt):** Der Monats-Snapshot (`backoffice:store-statistics-snapshots`) friert **alle** Kennzahlen ein — auch die Abo-Zahlen (Teamabos, Teamkundenabos, Führungskräfte-Liste). Diese Abo-Zahlen sind aber **Bestandszahlen ohne Monatsbezug** (Zählung `activeAbo` ohne Datumsfilter). Wählt man einen vergangenen Monat, sieht man den eingefrorenen Bestand vom Snapshot-Zeitpunkt statt aktueller Zahlen. Nur die `*_neu`-Zahlen sind echt monatsbezogen.
|
||||
|
||||
**Konzept (vom Kunden bestätigt, F7):** Beim Anzeigen eines Snapshot-Monats die **Bestands-Kennzahlen (Abos) live nachrechnen** und über den Snapshot-Payload legen; die monatsbezogenen Zahlen (Punkte, Neupartner, neue Abos) weiter aus dem Snapshot. So sind Abo-Bestände immer tagesaktuell, historische Monatswerte bleiben stabil.
|
||||
|
||||
### B3 — Neupartner-Liste: Sponsor + tagesaktuelle Kundenpunkte
|
||||
|
||||
**Ist-Stand:** Der Drilldown der Neupartner-KPI-Card zeigt Name, E-Mail, Karriere-Level, Aktiv-seit, Account-Gültigkeit — **weder Sponsor noch Punkte**.
|
||||
|
||||
**Konzept:** `BackofficeDrilldownService::newPartnerRows()` erweitern:
|
||||
- **Sponsor**: Join auf `m_sponsor` → Sponsorname als Spalte.
|
||||
- **Kundenpunkte (tagesaktuell)**: `externalPoints`-Summe des laufenden Monats je Neupartner über die vorhandenen `SalesVolumePoints`-SQL-Fragmente.
|
||||
- View `details.blade.php` um die beiden Spalten ergänzen. Kleiner, klar umrissener Eingriff.
|
||||
|
||||
### B4 — 1000-Punkte-Shop: eigene Person zählt mit
|
||||
|
||||
**Ist-Stand:** `lineBuckets()` beginnt bei den Kindern des Root-Users — der VIP selbst ist in `shop1000Count()` und im Drilldown **nie enthalten** (gleiches Muster auf dem Home-Dashboard via `activeTeamUserIds()`).
|
||||
|
||||
**Konzept:** Für die 1000-Punkte-Kennzahl (KPI-Card, Drilldown, Home-KPI) die Root-User-ID mit in die geprüfte ID-Menge aufnehmen. Erreicht der User selbst ≥ 1000 Punkte, erscheint er in Zählung und Liste (z. B. mit Kennzeichnung „(ich)"). Bewusst **nur** für diese Metrik — die übrigen Teamzahlen bleiben ohne den Root-User.
|
||||
|
||||
### B5 + B6 — Top-10 Kundenabos (Anzahl und Umsatz/Punkte)
|
||||
|
||||
**Kundenwunsch:** Zwei Ranglisten „unabhängig vom Incentive": Top 10 nach Anzahl Kundenabos (z. B. „Platz 1: Monika Kunz, 15 Kundenabos") und Top 10 nach Kundenabo-Umsatz in Punkten.
|
||||
|
||||
**Ist-Stand:** Es gibt bereits das Ranking „Neue Kundenabos" (nur Neuzugänge im Monat). Ein Gesamt-Ranking (Bestand) existiert nicht.
|
||||
|
||||
**Konzept (Entscheidungen aus F8 eingearbeitet):**
|
||||
- Zwei neue Listen auf der VIP-Statistik-Übersicht, technisch analog `newCustomerAboRanking()` in `BackofficeDashboardService`. **Scope: das eigene Team** des Statistik-Nutzers (nicht unternehmensweit).
|
||||
- **Top 10 Anzahl:** aktive Kundenabos (`is_for = ot`) je Berater zählen — **Gesamtbestand, unabhängig vom Incentive** —, absteigend, Top 10.
|
||||
- **Top 10 Umsatz mit zwei Werten „Soll" und „Ist"** (Kundenwunsch):
|
||||
- **Soll** = Abo-Punkte-Bestand je Berater (Produktpunkte × Menge der laufenden Kundenabos, `getTotalPoints()`-Logik als SQL-Summe) — was die Abos pro Zyklus bringen.
|
||||
- **Ist** = im gewählten Monat tatsächlich abgerechneter Kundenabo-Umsatz (Abo-Punkte aus `user_sales_volumes` über die `SalesVolumePoints`-Fragmente).
|
||||
- Sortierung nach Soll, Ist als zweite Wertspalte daneben.
|
||||
- Darstellung mit Platz-Nummerierung (Platz 1, 2, …), Name, Wert(e); Linie/Level klein darunter wie bei den bestehenden Listen.
|
||||
|
||||
### B7 — Führungskräfte-Statistik: 4 Kennzahlen + Gesamtsumme
|
||||
|
||||
**Kundenwunsch (3× in der Liste genannt, eine Anforderung):** Pro Führungskraft (ab Vertriebsleiter): eigenes Abo, eigene Kundenabos, **Teamabos**, **Kundenabos im Team**, plus **Summe aller 4 Kennzahlen**.
|
||||
|
||||
**Ist-Stand:** Die Liste „Abos Führungskräfte" (`leaderAboOverview()`) zeigt nur die **eigenen** Abos und **eigenen** Kundenabos je Führungskraft. Downline-Aggregate pro Führungskraft gibt es nicht.
|
||||
|
||||
**Konzept:**
|
||||
1. `leaderAboOverview()` erweitern: pro Führungskraft den eigenen Teilbaum ermitteln (BFS über `m_sponsor`, Mechanik aus `lineBuckets()`/`teamUserIds()` wiederverwendbar) und darauf zählen:
|
||||
- `team_abo_count`: aktive Berater-Abos (`is_for = me`) der Downline
|
||||
- `team_customer_abo_count`: aktive Kundenabos (`is_for = ot`) der Downline
|
||||
2. Neue Spalten + Summenspalte `total = own + own_customer + team + team_customer` in [index.blade.php](resources/views/user/backoffice/statistics/index.blade.php), Sortierung nach Summe.
|
||||
3. **Performance:** Teilbäume aller Führungskräfte einmalig aus einer gemeinsamen Sponsor-Map im Speicher ableiten (ein Query für alle User-Beziehungen, keine N Abfragen pro Leader). Ergebnis landet mit im Monats-Snapshot (alte Snapshots zeigen dann den bestehenden „bitte Snapshot neu erzeugen"-Hinweis).
|
||||
4. **Zählweise (entschieden, F9): an der nächsten Führungskraft abschneiden.** Der Teilbaum einer Führungskraft endet, sobald darunter die nächste Führungskraft (ab Vertriebsleiter) beginnt — deren Team zählt nur bei ihr selbst. Vorteil: keine Überlappungen, die Teamzahlen der Führungskräfte sind untereinander additiv.
|
||||
|
||||
### B8 — Abo-Gesamtumsatz in der Payline (Abo-Statistik)
|
||||
|
||||
**Kundenwunsch:** Auf der Abo-Statistik zusätzlich den Abo-Gesamtumsatz **in der Payline** ausweisen.
|
||||
|
||||
**Ist-Stand:** Die Team-Abo-Seite zeigt Abo-Punkte pro Linie und gesamt (Bestandspunkte). Eine Verknüpfung „Abo-Punkte ∩ Payline" existiert nicht; die Payline wird nur im Live-`TreeCalcBot` als Gesamtpunktzahl berechnet.
|
||||
|
||||
**Konzept (entschieden, F10 — „Soll und Ist"):** Die Payline-Berechnung liefert (bzw. kann liefern), welche Linien/User in der Payline des Users liegen. Darauf aufbauend zwei Kennzahlen auf der Abo-Statistik-Seite, gecacht analog `home_payline_*`:
|
||||
- **Soll** = Summe der aktiven Abo-Punkte aller Payline-Mitglieder (Berater-Abos + Kundenabos), Stand heute.
|
||||
- **Ist** = im laufenden Monat bereits abgerechneter Abo-Umsatz der Payline-Mitglieder (aus `user_sales_volumes`).
|
||||
|
||||
Voraussetzung ist, dass der Live-Bot die Payline-Mitglieder-IDs exponiert (kleine Erweiterung an `TreeCalcBot`).
|
||||
|
||||
### B9 — „Neue Abos" klickbar: wer sind sie?
|
||||
|
||||
**Ist-Stand:** Die `+N`-Badges (Home-Dashboard-Kacheln und VIP-Übersicht) sind reine Anzeige; die Kacheln verlinken nur auf generische Abo-Listen. Der Abo-Drilldown markiert neue Abos mit „Neu"-Badge, filtert aber nicht.
|
||||
|
||||
**Konzept:** Der vorhandene Abo-Drilldown (`metric=team_partner_abos` / `team_customer_abos`) bekommt einen Filter-Parameter `only_new=1` (Filter auf `start_date` im gewählten Monat — Logik aus `newAboCount()` existiert). Die `+N`-Badges werden zu Links auf den gefilterten Drilldown. Auf dem Home-Dashboard verlinken die Badges auf dieselben Drilldowns.
|
||||
|
||||
### B10 — Payline-Prognose: voraussichtlicher Gesamtumsatz am 5./10./20./25.
|
||||
|
||||
**Kundenwunsch:** Vier Kennzahlen auf der Übersicht (HOME): voraussichtlicher Payline-Gesamtumsatz zum 5./10./20./25. = aktueller Kundenumsatz + Payline-Gesamtumsatz Stand heute **+ Abo-Umsätze der Payline, die bis zum jeweiligen Termin dazukommen**. Ziel: Anfang des Monats absehen, wo die Payline an den Ausführungsterminen stehen wird.
|
||||
|
||||
**Ist-Stand / Datenbasis (gut vorhanden):**
|
||||
- Feste Ausführungstage existieren als `UserAbo::$aboDeliveryDays = [5, 10, 20, 25]`; jedes Abo trägt `abo_interval` (gewählter Tag) und `next_date` (konkreter nächster Termin).
|
||||
- Punkte je Abo über `getTotalPoints()` (Produktpunkte × Menge).
|
||||
- Aktueller Kundenumsatz und Live-Payline-Punkte werden auf dem Home-Dashboard bereits berechnet.
|
||||
- Eine Auswertung „Abo-Umsätze pro Ausführungstag" existiert noch nirgends.
|
||||
|
||||
**Konzept:**
|
||||
1. Neuer Service (z. B. `PaylineForecastService`): ermittelt für den laufenden Monat je Termin T ∈ {5, 10, 20, 25}:
|
||||
`Prognose(T) = Kundenumsatz heute + Payline-Punkte heute + Σ Abo-Punkte aller aktiven Abos in der Payline mit next_date im laufenden Monat und Tag ≤ T, die heute noch nicht ausgeführt sind.`
|
||||
Bereits ausgeführte Abos stecken automatisch im Ist-Umsatz und werden nicht doppelt gezählt (Abgrenzung über `next_date` > heute).
|
||||
2. Kumulative Logik (bestätigt, F11): die Prognose zum 25. enthält auch die Termine 5./10./20. (sofern noch offen) — „so steht die Payline am 25.".
|
||||
3. **Payline-Zugehörigkeit der Abos — Annahme zu F12 (vom Kunden noch nicht final entschieden):**
|
||||
- **Vorschlag/Umsetzung:** Die **eigenen Abos und eigenen Kundenabos** des Beraters **zählen mit** — zusätzlich zu den Abos der Payline-Downline (Mitglieder-Menge wie B8). Begründung: Die Basis der Formel enthält den aktuellen Kundenumsatz; kommende eigene Abo-Ausführungen erhöhen genau diesen Anteil. Ohne sie wäre die Prognose systematisch zu niedrig.
|
||||
- **Umsetzungs-Notiz (wichtig):** Die Auswahl wird an EINER Stelle im `PaylineForecastService` gekapselt (Parameter/Konstante `includeOwnAbos`, Default `true`), sodass die Entscheidung später ohne Umbau umkehrbar ist. Nach der Umsetzung bekommt der Kunde eine Notiz, wie es umgesetzt wurde, mit der Frage, ob es so bleiben soll (siehe Entscheidungen, F12).
|
||||
4. Anzeige: vier Kacheln (oder eine kleine Tabelle „Prognose") auf dem Home-Dashboard mit „Live"-Kennzeichnung; Cache zusammen mit `home_payline_*` (6 h), tagesaktuell wie vom Kunden beschrieben.
|
||||
5. Ausfall-Risiken (fehlgeschlagene Zahlungen, Pausen, Kündigungen bis zum Termin) machen die Zahl naturgemäß zur **Prognose** — im UI so benennen.
|
||||
|
||||
B8 und B10 teilen sich die Grundlage „Payline-Mitglieder + deren aktive Abos" und sollten zusammen umgesetzt werden.
|
||||
|
||||
---
|
||||
|
||||
## Empfohlene Umsetzungsreihenfolge
|
||||
|
||||
1. **Schnelle, klar definierte Pakete:** B3, B4, B9, A2 (Prüfung/Feinschliff)
|
||||
2. **Statistik-Ausbau:** B5/B6, B7, B2
|
||||
3. **Abo-Pause:** A1 (nach Klärung F1/F2)
|
||||
4. **Payline/Provision (aufwändig, gemeinsame Basis):** B8 + B10, danach B1
|
||||
|
||||
---
|
||||
|
||||
## Entscheidungen (Kundenantworten vom 2026-07-22)
|
||||
|
||||
| # | Frage (Kurzform) | Entscheidung |
|
||||
|---|---|---|
|
||||
| F1 | Pause-Voraussetzung | **6 bezahlte Lieferungen** (wie Kündigung, gegen `abo-min-duration`) |
|
||||
| F2 | Pause-Zyklus | **Rollierend**: letzte Pause muss X Monate zurückliegen; X als **änderbares Setting** (`abo-pause-interval-months`, Default 6) |
|
||||
| F3 | Kündigung „Softdelete" | Status-Modell bestätigt (nachvollziehbar für Statistik/Abrechnung). **Wichtig:** Nach Kündigung darf die Ein-Abo-Regel ein **neues Abo nicht blockieren** — gekündigte Abos zählen bei der Prüfung nicht mit |
|
||||
| F4 | Mindestlaufzeit Kündigung | Bestätigt: ab der 6. **bezahlten** Lieferung; Setting in Produktion auf 6 + Code-Default 6 |
|
||||
| F5 | Provision live — Umfang | **Alle Bestandteile** (Payline + Shop + Wachstum); Aktualisierung alle ~6 h |
|
||||
| F6 | Provision live — Sichtbarkeit | **Jeder Berater** (Home-Dashboard) |
|
||||
| F7 | Abo-Zahlen bei Monatswechsel | Wie empfohlen: Abo-Bestände **live/tagesaktuell**, Monats-Punkte/-Umsätze bleiben eingefroren |
|
||||
| F8 | Top-10-Listen | a) Scope: **eigenes Team**; b) **Bestand aktiver Kundenabos**, unabhängig vom Incentive; c) Umsatz-Ranking mit **Soll UND Ist** |
|
||||
| F9 | Verschachtelte Führungskräfte | **An der nächsten Führungskraft abschneiden** (keine Überlappung, Zahlen additiv) |
|
||||
| F10 | Payline-Abo-Umsatz | **Soll und Ist** (Abo-Punkte-Bestand + abgerechneter Monatsumsatz) |
|
||||
| F11 | Prognose kumuliert | **Ja**, kumuliert (25. enthält 5./10./20.) |
|
||||
| F12 | Eigene Abos in der Prognose | **Vom Kunden offen gelassen** — siehe unten |
|
||||
|
||||
### F12 — dokumentierte Annahme (Kunde entscheidet nach Umsetzung)
|
||||
|
||||
Der Kunde konnte nicht entscheiden, ob eigene Abos/eigene Kundenabos des Beraters in die Payline-Prognose (B10) einfließen; wir sollen einen Vorschlag umsetzen und dokumentieren.
|
||||
|
||||
- **Umgesetzt wird (Vorschlag):** Eigene Abos + eigene Kundenabos **zählen mit**, weil die Prognose-Basis den aktuellen Kundenumsatz enthält und kommende eigene Abo-Ausführungen genau diesen erhöhen. Ohne sie wäre die Prognose systematisch zu niedrig.
|
||||
- **Umkehrbar:** Die Auswahl liegt an einer Stelle im `PaylineForecastService` (Schalter `includeOwnAbos`, Default `true`).
|
||||
- **Offener Punkt an den Kunden nach Umsetzung:** „Die Prognose zum 5./10./20./25. enthält auch deine eigenen Abos und deine Kundenabos. Soll das so bleiben oder sollen nur die Team-Abos der Payline zählen?"
|
||||
106
dev/2026-07-22/feedback-an-kundin-statistiken.md
Normal file
106
dev/2026-07-22/feedback-an-kundin-statistiken.md
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
# Rückmeldung zu deinem Dokument „Statistiken kontrolliert"
|
||||
|
||||
Vielen Dank für deine gründliche Kontrolle — sie war sehr hilfreich! Wir sind alle Punkte einzeln durchgegangen und haben jede Zahl mit der Datenbank nachgerechnet.
|
||||
|
||||
**Das Wichtigste vorweg: Alle Zahlen im System stimmen — es gibt keinen Rechenfehler.** An einigen Stellen zählen zwei Ansichten aber bewusst unterschiedlich (das erklären wir unten), und einige deiner Wünsche waren berechtigte Verbesserungen, die wir jetzt umgesetzt haben. Hier die Antworten zu jedem deiner Punkte:
|
||||
|
||||
---
|
||||
|
||||
## 1. Neue Kundenabos — „hier muss ICH auch in die Liste"
|
||||
|
||||
✅ **Umgesetzt.** Du erscheinst jetzt selbst in der Liste, sobald du neue Kundenabos abgeschlossen hast. Deine Zeile ist mit einem kleinen „ich"-Zeichen markiert.
|
||||
|
||||
## 2. Top 10 Kundenabos (Anzahl)
|
||||
|
||||
✅ **Du bist jetzt dabei:** Wenn du selbst unter den Top 10 bist, erscheinst du in der Liste (ebenfalls mit „ich"-Zeichen).
|
||||
|
||||
**Zu den Zahlen (Rosmarie 14 statt 13, Tanja 16 statt 13 usw.):** Wir haben jede einzelne Zahl nachgerechnet — **beide Listen stimmen**, sie zählen nur zwei verschiedene Dinge:
|
||||
|
||||
- Die **Montenegro-Rangliste** zählt alle Abos, die im Aktionszeitraum (1. April bis 31. Juli) gestartet sind. Was einmal gezählt wurde, bleibt gezählt — auch wenn das Abo inzwischen beendet oder gelöscht wurde. Außerdem zählt dort das **eigene Abo der Beraterin mit** (deshalb „17/6" bei Tanja).
|
||||
- Die **Top-10-Liste** zeigt dagegen den **aktuellen Bestand**: nur Kundenabos, die heute wirklich laufen.
|
||||
|
||||
Am Beispiel Tanja: Montenegro zeigt 17 = 13 heute laufende Kundenabos + 1 eigenes Abo + 3 Abos, die im Aktionszeitraum gestartet, inzwischen aber beendet oder gelöscht wurden. Beide Zahlen sind also richtig — sie beantworten nur verschiedene Fragen („Was wurde für das Incentive geschafft?" vs. „Was läuft heute?").
|
||||
|
||||
Ein Teil der Differenz kam übrigens von den Kundenabos, die wegen Zahlungsproblemen gelöscht und neu angelegt wurden — die alten zählen im Incentive weiter, im Bestand natürlich nicht. Damit das nicht mehr verwirrt, steht der Unterschied jetzt direkt als Hinweis an der Top-10-Liste. Zusätzlich haben wir intern dafür gesorgt, dass gelöschte Abos sauber als beendet markiert werden.
|
||||
|
||||
## 3. Top 10 Kundenabos (Umsatz)
|
||||
|
||||
✅ **Umgesetzt, genau wie gewünscht:**
|
||||
|
||||
- Die Liste zeigt jetzt als Hauptwert die **im laufenden Monat bereits angerechneten Kundenabo-Punkte** — danach wird auch sortiert.
|
||||
- Daneben steht jetzt die **Anzahl der Kundenabos**, mit denen die Beraterin diesen Umsatz gemacht hat (dein Beispiel: „Tanja: 1.123 Punkte mit 16 Kundenabos").
|
||||
- Du selbst erscheinst ebenfalls, wenn du unter den Top 10 bist.
|
||||
|
||||
**Zu deiner Frage, warum „Soll" mal höher und mal niedriger war als „Ist":** Der bisherige „Soll"-Wert war der heutige Abo-Bestand — also was die aktuell laufenden Abos pro Lieferung bringen. Der „Ist"-Wert ist, was im Monat schon angerechnet wurde. Wenn ein Abo Anfang des Monats noch geliefert wurde und danach beendet wurde, steckt es im Ist, aber nicht mehr im Bestand — dann ist Ist höher. Kommt ein neues Abo dazu, dessen Liefertag noch aussteht, ist es umgekehrt. Beide Werte sind weiterhin sichtbar, aber jetzt klar beschriftet: **„Abgerechnet"** und **„Bestand/Lieferung"**.
|
||||
|
||||
## 4. Abos Führungskräfte — „stimmen leider nicht"
|
||||
|
||||
Hier stimmen die Zahlen — die Liste hat aber anders gezählt, als du erwartet hast, und zwar so, wie es ursprünglich gemeinsam festgelegt wurde: Jede Führungskraft zählte ihr Team nur **bis zur nächsten Führungskraft**. Der Grund: So wird kein Abo doppelt gezählt, und die Gesamtsumme oben geht exakt auf.
|
||||
|
||||
Deine Rechnung zählt dagegen die **komplette Downline** — das haben wir nachgerechnet und es stimmt exakt (Nina: 21 Teamabos + 52 Teamkundenabos, genau wie von dir angegeben; Rosmarie lag bei unserer Prüfung bei 82 + 201, dein Stand 81 + 200 war vom Vortag).
|
||||
|
||||
✅ **Umgesetzt — die Tabelle zeigt jetzt beides:**
|
||||
|
||||
- **„direkt"** = das eigene Team bis zur nächsten Führungskraft (diese Spalten gehen in der Summe oben exakt auf),
|
||||
- **„gesamt"** = die komplette Downline, so wie du gezählt hast. Die **Gesamt-Spalte** rechnet jetzt genauso wie du: eigenes Abo + Kundenabos + alle Teamabos + alle Teamkundenabos.
|
||||
|
||||
Wichtig zu wissen: In der „gesamt"-Sicht zählt dasselbe Abo bei mehreren Führungskräften mit (Ninas Team steckt z. B. auch komplett in Rosmaries Zahlen). Deshalb kann man diese Spalten nicht einfach aufaddieren — ein kurzer Hinweis dazu steht unter der Tabelle. Schau es dir bitte an und gib uns Bescheid, ob dir beide Sichten helfen oder ob eine davon reicht.
|
||||
|
||||
## 5. Statistik 97.453 vs. Struktur 92.211 — „theoretisch gleich, warum verschieden?"
|
||||
|
||||
Beide Zahlen sind richtig — sie enthalten unterschiedliche Punkte:
|
||||
|
||||
- Die **Statistik** zählt **alle** Umsatzpunkte deines Teams.
|
||||
- Die **Struktur-/Provisionsansicht** zählt nur die Punkte, die in deine **Payline** fließen und damit provisionsrelevant sind.
|
||||
|
||||
Der Unterschied sind Punkte, die einer Beraterin persönlich gutgeschrieben werden, aber nicht in die Payline wandern — vor allem die **Einschreibungspunkte für Neupartner** (bei deiner Kontrolle: 19 Neupartner-Einschreibungen plus eine Gutschrift, zusammen genau die Differenz).
|
||||
|
||||
✅ **Umgesetzt:** Unter der Statistik-Tabelle steht jetzt ein Hinweis, wie viele der Gesamtpunkte nicht payline-wirksam sind — dann sieht man auf einen Blick, warum die Struktur-Ansicht niedriger liegt.
|
||||
|
||||
## 6. Team-Abos: „Was bedeuten diese 42.628 Abo-Punkte?"
|
||||
|
||||
Diese Zahl ist der **Bestand**: die Punkte, die alle aktuell laufenden Abos deines Teams **pro Lieferung** bringen würden — über alle 10 Ebenen deines Teams. (Die Payline-Karte darüber zeigt weniger, weil deine Payline nur die ersten 6 Ebenen umfasst.) Es ist also nicht der abgerechnete Umsatz.
|
||||
|
||||
✅ **Umgesetzt — die Tabelle „Abos nach Ebenen" zeigt jetzt drei Werte pro Ebene:**
|
||||
|
||||
1. **Punkte pro Lieferung (Bestand)** — die bisherige Zahl, jetzt klar beschriftet,
|
||||
2. **abgerechnet (Monat)** — was in diesem Monat schon angerechnet wurde,
|
||||
3. **bis zum 25. erwartet** — was bis zum letzten Liefertag des Monats voraussichtlich zusammenkommt (bereits Abgerechnetes plus die noch anstehenden Lieferungen).
|
||||
|
||||
Damit hast du genau die Sicht, die du dir gewünscht hast: was zum Stichtag 25. im aktuellen Monat abgerechnet sein wird.
|
||||
|
||||
## 7. Payline: „Wieviel Abos gibt es in der Payline?"
|
||||
|
||||
✅ **Umgesetzt.** Unter der Payline-Karte steht jetzt zusätzlich, wie viele Abos dahinterstecken, bei deinem Account aktuell: „Payline = 6 Ebenen · 502 Berater — 129 Teamabos + 340 Kundenabos in der Payline."
|
||||
|
||||
## 8. Live-Rangliste Montenegro — Nummerierung
|
||||
|
||||
✅ **Umgesetzt, genau wie von dir beschrieben:** Die Plätze werden jetzt so vergeben, dass die **qualifizierten** Beraterinnen oben stehen und fortlaufend Platz 1, 2, 3, … tragen. Wer mehr Punkte hat, aber die Mindestqualifikation (Partner/Abos) noch nicht geschafft hat, wird **darunter** eingereiht — und springt nach oben, sobald die Qualifikation erreicht ist. Dein Beispiel: Tanja steht mit den meisten Punkten, aber ohne Mindestqualifikation, jetzt auf Platz 5 — nicht auf Platz 1.
|
||||
|
||||
Der Fix hatte noch einen positiven Nebeneffekt: Die Gewinner-Ermittlung („die besten 20 qualifizierten") arbeitet jetzt ebenfalls sauber nach dieser Reihenfolge.
|
||||
|
||||
## 9. Portokosten Deutschland: Abo 19,90 € vs. Einzelbestellung 16,72 €
|
||||
|
||||
Gute Nachricht: **Es gibt gar keinen Preisunterschied.** Beide Zahlen sind derselbe Versandsatz — einmal **mit** Mehrwertsteuer (19,90 €) und einmal **ohne** (16,72 €; 16,72 € + 19 % MwSt = 19,90 €). Die Abo-Ansicht zeigte den Betrag inklusive Steuer, die Einzelbestellungs-Übersicht ohne — deshalb sah es wie ein Aufschlag aus.
|
||||
|
||||
In Österreich fällt der Unterschied nicht auf, weil dort für Teampartner mit gültiger UID-Nummer keine deutsche Mehrwertsteuer berechnet wird (Reverse Charge) — darum stand dort in beiden Ansichten derselbe Betrag (25,21 €).
|
||||
|
||||
✅ **Umgesetzt:** Die Anzeige ist jetzt einheitlich und eindeutig beschriftet — bei Beratern (die als Selbstständige netto rechnen) steht der Nettobetrag mit dem Zusatz **„(netto)"**, bei Endkunden der Bruttobetrag mit **„(inkl. MwSt.)"**. Abgebucht wurde immer der korrekte Betrag; es war rein eine Darstellungsfrage.
|
||||
|
||||
## 10. Struktur: Kennzeichen für Abos je Teampartner
|
||||
|
||||
✅ **Umgesetzt.** In der Struktur-Ansicht steht jetzt bei jedem Teampartner:
|
||||
|
||||
- ein grünes **„A"**, wenn er/sie ein eigenes laufendes Abo hat,
|
||||
- daneben eine rote Zahl, z. B. **(8)**, für die Anzahl der eigenen Kundenabos.
|
||||
|
||||
Beim Drüberfahren mit der Maus wird die Bedeutung angezeigt. Gezählt wird genauso wie in der Statistik, damit die Zahlen überall zusammenpassen.
|
||||
|
||||
---
|
||||
|
||||
## Was wir noch von dir brauchen
|
||||
|
||||
1. **Führungskräfte-Liste (Punkt 4):** Schau dir bitte die neue Tabelle mit den beiden Sichten „direkt" und „gesamt" an — reicht dir eine davon oder sollen beide bleiben?
|
||||
2. **Payline-Vorschau (offen aus der letzten Runde):** In der Vorschau auf die Liefertage zählen aktuell auch deine **eigenen** Abos und Kundenabos mit. Soll das so bleiben oder sollen nur die Team-Abos der Payline zählen?
|
||||
|
||||
Alle Änderungen sind auf dem Testsystem bereits aktiv — du kannst alles direkt mit deinem Zugang nachprüfen. Melde dich gern, wenn eine Zahl nicht zu deiner Erwartung passt: Wie du an den Punkten oben siehst, lohnt sich das Nachrechnen immer — entweder finden wir eine Verbesserung oder eine gute Erklärung.
|
||||
261
dev/2026-07-22/feedback-analyse-2026-07-24.md
Normal file
261
dev/2026-07-22/feedback-analyse-2026-07-24.md
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
# Analyse: Feedback „Statistiken kontrolliert" (2026-07-24)
|
||||
|
||||
Basis: `dev/2026-07-22/Statistiken kontrolliert.pdf` (Kundin, getestet mit User **454** / info@claudialattner.com).
|
||||
Alle Zahlen wurden am **2026-07-24** gegen die aktuelle Test-DB verifiziert (Datenstand von heute).
|
||||
|
||||
> **Umsetzungsstand (2026-07-24): ✅ ALLE Punkte sind gemäß den „++"-Antworten des Kunden umgesetzt** —
|
||||
> Details je Punkt im jeweiligen Abschnitt „Umgesetzt". Tests: `BackofficeStatisticsListsTest` (12),
|
||||
> `TeamAboOverviewTest` (11), `PaylineForecastServiceTest` (6), `AboAdminDeleteTest` (3),
|
||||
> `Incentive/IncentiveRankingTest` (4, neu), `TeamStructureAboBadgesTest` (5, neu),
|
||||
> `YardShippingTaxTest` (neu) + erweiterte `AboOneTimeViewTest` — alle grün.
|
||||
> Einziger roter Test im Umfeld: `IncentiveTeaserPageTest` (Galerie-Bild `nikki-beach.jpg` liegt lokal im
|
||||
> Ordner) — vorbestehend, ohne Bezug zu diesen Änderungen.
|
||||
|
||||
**Gesamtbild:** Von den 10 Punkten sind **2 echte Fixes** (Rang-Nummerierung Incentive, Porto-Anzeige),
|
||||
**3 kleine Erweiterungen** („ich" in Listen, Abo-Zähler Payline, Struktur-Symbole),
|
||||
**2 Erklärungs-/Beschriftungsthemen** (Zahlen stimmen, sind aber unterschiedliche Metriken)
|
||||
und **1 Konzeptänderung** (Führungskräfte: komplette Downline statt Abschneiden — widerspricht der früheren Entscheidung F9).
|
||||
Alle vom Kunden gemeldeten „falschen" Zahlen ließen sich vollständig aufklären — **kein Rechenfehler in den neuen Statistiken**.
|
||||
|
||||
| # | Punkt | Einstufung | Aufwand |
|
||||
|---|---|---|---|
|
||||
| 1 | „Neue Kundenabos": ich selbst fehlt | Erweiterung (bewusster Ist-Stand) | klein |
|
||||
| 2a | Top 10 Anzahl: ich selbst fehlt | Erweiterung | klein (mit 1) |
|
||||
| 2b | Top 10 Anzahl ≠ Montenegro-Zahlen | **kein Bug** — andere Metrik, erklärbar | Doku/UI-Text |
|
||||
| 3 | Top 10 Umsatz: Soll/Ist unklar, Abo-Anzahl fehlt | Erweiterung + Beschriftung | klein |
|
||||
| 4 | Führungskräfte-Zahlen „stimmen nicht" | **Konzeptänderung** (F9 wird revidiert) | klein–mittel + Rückfrage |
|
||||
| 5 | Statistik 97.453 ≠ Struktur 92.211 | **kein Bug** — Neupartner-Punkte sind nicht payline-wirksam | Doku/UI-Text |
|
||||
| 6 | Was bedeuten 42.628 Abo-Punkte? | Erklärung + sinnvolle Erweiterung (Ist je Ebene) | klein–mittel |
|
||||
| 7 | Payline: Anzahl Abos anzeigen | Erweiterung | klein |
|
||||
| 8 | Montenegro: Nummerierung falsch | **Fix** (Ranking-Sortierung) | klein, aber mit Nebenwirkungen prüfen |
|
||||
| 9 | Porto DE Abo 19,90 vs. Einzel 16,72 | **Anzeige-Fix** (brutto vs. netto) | klein |
|
||||
| 10 | Struktur: Abo-Symbole je Teampartner | neue Anforderung | mittel |
|
||||
|
||||
---
|
||||
|
||||
## 1. „Neue Kundenabos": eigene Person muss erscheinen
|
||||
|
||||
**Feedback:** „Hier muss ICH auch in die Liste aufgenommen werden, sollte ich neue Kundenabos abgeschlossen haben."
|
||||
|
||||
**Befund/Ursache:** Alle Team-Auswertungen der VIP-Statistik basieren auf `lineBuckets()`
|
||||
([BackofficeDashboardService.php:215-240](app/Services/Backoffice/BackofficeDashboardService.php#L215-L240)).
|
||||
Dort steht der Root-User von Anfang an in `visitedUserIds` und wird per `whereNotIn('id', …)` in **jeder** Linie ausgeschlossen.
|
||||
Damit fehlt die eingeloggte Person in „Neue Kundenabos" (`newCustomerAboRanking()`, Z. 499-533), in beiden Top-10-Listen und in der Führungskräfte-Liste.
|
||||
Einzige bisherige Ausnahme ist `shop_1000` — dort wurde der Root-User auf Kundenwunsch (B4) explizit ergänzt (Z. 96-98, Muster „(ich)" existiert also schon).
|
||||
|
||||
Hinweis: User 454 hat aktuell **0 neue Kundenabos im Juli** — sie würde in dieser Liste derzeit ohnehin nicht auftauchen. Die Anforderung ist trotzdem korrekt.
|
||||
|
||||
**Empfehlung:** Root-User-ID in die Kandidatenmenge von `newCustomerAboRanking()` und `topCustomerAboRanking()` aufnehmen (Linie 0 / Markierung „(ich)", analog `shop_1000`). Kleiner, klar umrissener Eingriff + Test.
|
||||
|
||||
++ so umsetzen wie in der Empfehlung
|
||||
|
||||
**Umgesetzt (2026-07-24):** `usersWithLine()` nimmt optional den Root-User als Linie 0 auf; `newCustomerAboRanking()` und `topCustomerAboRanking()` (beide Listen) zählen ihn jetzt mit. Neue Zeilen tragen `is_self`; die View zeigt ein „ich"-Badge und „Eigene Kundenabos" statt „Linie 0". Test: „nimmt den Statistik-Nutzer selbst in die Kundenabo-Ranglisten auf".
|
||||
|
||||
## 2. Top 10 Kundenabos (Anzahl)
|
||||
|
||||
### 2a. Eigene Person fehlt
|
||||
Gleiche Ursache und Empfehlung wie Punkt 1. Zum Vergleich: User 454 hat aktuell **7 aktive Kundenabos** — wäre also derzeit nicht in den Top 10 (Platz 10 = 10 Abos), muss aber grundsätzlich mitspielen.
|
||||
|
||||
++ so umsetzen wie in der Empfehlung
|
||||
|
||||
**Umgesetzt (2026-07-24):** mit Punkt 1 erledigt (beide Top-10-Listen inkl. „ich").
|
||||
|
||||
### 2b. „Zahlen stimmen nicht" (Rosmarie 14 statt 13, Tanja 16 statt 13, …)
|
||||
|
||||
**Befund: Beide Zahlen sind korrekt — es sind zwei verschiedene Metriken.** Die Service-Methode liefert heute exakt die Screenshot-Werte (Tanja 13 usw.), und die Montenegro-Werte stimmen ebenfalls exakt mit der DB überein.
|
||||
|
||||
Die Montenegro-Spalte „ABOS" zählt etwas anderes als die Top-10-Liste:
|
||||
|
||||
| | Top 10 Kundenabos (Anzahl) | Montenegro „ABOS x/6" |
|
||||
|---|---|---|
|
||||
| Was | **aktueller Bestand** aktiver Kundenabos | **im Qualifikationszeitraum (1.4.–31.7.) aktivierte** Abos |
|
||||
| Eigenabo des Beraters | zählt nicht (`is_for='ot'`) | zählt mit (+1, inkl. Alt-Eigenabo) |
|
||||
| Inzwischen gelöschte/inaktive Abos | zählen nicht | bleiben gezählt (Tracking-Tabelle `incentive_new_abos`, einmal erreicht = bleibt) |
|
||||
| Vor dem Zeitraum angelegte Abos | zählen (solange aktiv) | zählen, wenn **Aktivierung/erste Zahlung** in den Zeitraum fällt |
|
||||
|
||||
Konkret nachgerechnet (DB-Stand heute), Beispiel **Tanja Longerich** (Montenegro 17 vs. Top-10 13):
|
||||
|
||||
- 13 = aktive, nicht gelöschte Kundenabos heute (Top-10-Wert ✓)
|
||||
- 17 = 13 + **1 Eigenabo** + **2 im Juli gelöschte** Kundenabos (soft-delete am 06.07./21.07., im Mai angelegt) + **1 inzwischen storniertes** Abo (Status 6, Januar-Abo, erste Ausführung im April)
|
||||
|
||||
Analog: Rosa Maria Assenbaum 15 = 13 + 1 Eigenabo + 2 gelöschte (16.07./21.07.) − 1 vor dem Zeitraum aktiviertes; Monika Kunz 16 = 13 + 1 + 2; Melanie Hoffmann-Gebhard 15 = 13 + 1 + 1.
|
||||
Die Erwartung der Kundin („Tanja 16") ist die Montenegro-Kundenabo-Zahl (17 − 1 Eigenabo) — kein Bestand.
|
||||
|
||||
Code: Top-10 [BackofficeDashboardService.php:764-776](app/Services/Backoffice/BackofficeDashboardService.php#L764-L776) (`active=1`, `status NOT IN (4,5,6)`, SoftDelete-Scope) vs. Incentive [IncentiveParticipant.php:300-354](app/Models/IncentiveParticipant.php#L300-L354) + [IncentiveTracker.php:85-135](app/Services/Incentive/IncentiveTracker.php#L85-L135).
|
||||
|
||||
**Empfehlungen:**
|
||||
1. **UI-Text schärfen:** Untertitel der Top-10-Karte z. B. „aktueller Bestand aktiver Kundenabos — weicht bewusst von der Incentive-Zählung ab (dort zählen alle im Aktionszeitraum gestarteten Abos dauerhaft)". Dazu eine kurze Erklärung an die Kundin (Text kann aus der Tabelle oben übernommen werden).
|
||||
2. **Daten-Auffälligkeit klären (separat!):** Es gibt im Team von 454 **7 Kundenabos, die im Juli per Soft-Delete gelöscht wurden, aber weiter `active=1`/Status 2-3 tragen** (Löschung vermutlich über den Admin-Delete [AboController.php:170-178](app/Http/Controllers/Admin/AboController.php#L170-L178), der Status/Flags nicht anfasst). Diese Abos werden durch den SoftDelete-Scope nirgends mehr ausgeführt oder gezählt — der inkonsistente Zustand (aktiv + gelöscht) sollte aber geprüft werden: War die Löschung beabsichtigt? Sollte `destroy()` zusätzlich `active=0`/Status setzen?
|
||||
|
||||
++ Umsetzen wie in der Empfehlung zusätzlich ja es ist richtig die Abos mussten gelöscht werden, da diese neu angelegt worden sind. Hier gab's Zahlungsprobleme grundsätzlich auf active null setzen und auch gleich die Funktion reparieren.
|
||||
|
||||
**Umgesetzt (2026-07-24):**
|
||||
- UI-Text: Untertitel der Karte lautet jetzt „aktueller Bestand aktiver Kundenabos · weicht bewusst von der Incentive-Zählung ab" mit Erklär-Tooltip.
|
||||
- Funktion repariert: `UserAbo` hat einen `deleting`-Hook — jeder Softdelete setzt jetzt automatisch `active = false` (gilt für Admin-`destroy()`, Portal-`deletePending()` und alle weiteren Model-Deletes). Test in `AboAdminDeleteTest` („setzt beim Softdelete auch active auf false").
|
||||
- Datenbereinigung: Migration `2026_07_24_120000_deactivate_soft_deleted_user_abos` deaktiviert alle bestehenden gelöschten-aber-aktiven Abos (auf dem Testsystem ausgeführt: 7 Datensätze bereinigt; läuft in Produktion beim Deploy).
|
||||
|
||||
## 3. Top 10 Kundenabos (Umsatz): Soll/Ist unklar, Abo-Anzahl fehlt
|
||||
|
||||
**Feedback:** Warum ist Soll mal höher, mal niedriger als Ist? Was heißt „pro Zyklus"? Gewünscht: bereits **angerechnete** Kundenabopunkte des Monats + Anzahl der Abos dahinter (z. B. „Tanja: 1123 Punkte mit 16 Kundenabos").
|
||||
|
||||
**Befund/Ursache:**
|
||||
- **Soll** = Punktebestand der *heute* aktiven Kundenabos (Produktpunkte × Menge, ohne gelöschte/stornierte) — „was die Abos pro Lieferzyklus bringen" ([BackofficeDashboardService.php:721-736](app/Services/Backoffice/BackofficeDashboardService.php#L721-L736)).
|
||||
- **Ist** = im gewählten Monat tatsächlich abgerechnete Kundenabo-Punkte aus `user_sales_volumes` ([Z. 744-756](app/Services/Backoffice/BackofficeDashboardService.php#L744-L756)).
|
||||
- **Ist > Soll** entsteht, wenn Abos nach ihrer Abrechnung gelöscht/storniert wurden (Beispiel Tanja: 2 im Juli gelöschte Abos wurden Anfang Juli noch abgerechnet → stecken im Ist 1.123, fehlen im Soll 992) oder Mengen/Artikel reduziert wurden. **Ist < Soll**, wenn Abos im Monat neu dazukamen und ihr Liefertermin noch aussteht. Beides ist korrekt, nur unglücklich erklärt.
|
||||
|
||||
**Empfehlung (kleiner Umbau):**
|
||||
1. **Ist als Hauptwert** (Sortierung nach Ist statt Soll — die Kundin denkt vom abgerechneten Monatsumsatz her), Soll als Zweitwert.
|
||||
2. **Spalte „Abos"** ergänzen (Anzahl aus `customerAboCounts()`, ist bereits berechnet).
|
||||
3. Beschriftung: „Soll" → z. B. „Punkte pro Lieferung (heutiger Bestand)", „Ist" → „im {Monat} abgerechnet".
|
||||
4. Eigene Person aufnehmen (Punkt 1/2a).
|
||||
|
||||
++ umsetzen wie in der Empfehlung.
|
||||
|
||||
**Umgesetzt (2026-07-24):** Sortierung nach Ist (`points_actual`), neue Spalte „Abos"; Beschriftungen: „Abgerechnet (Punkte)" (Hauptwert, fett) und „Bestand/Lieferung" (Zweitwert), Untertitel „sortiert nach abgerechneten Kundenabo-Punkten {Monat}" mit Erklär-Tooltip; „ich" inklusive. Test: „sortiert die Top-10-Umsatzliste nach abgerechneten Ist-Punkten und liefert die Abo-Anzahl mit".
|
||||
|
||||
## 4. Führungskräfte-Statistik: „Zahlen stimmen leider nicht"
|
||||
|
||||
**Feedback:** Teamabos/Team-Kundenabos sollen die **komplette Downline** umfassen. Beispiele: Rosmarie tatsächlich 81 Teamabos + 200 Teamkundenabos (Liste: 35 + 54), Nina 21 + 52 (Liste: 4 + 12).
|
||||
|
||||
**Befund: Kein Rechenfehler — die Liste tut exakt das, was in F9 entschieden wurde:** Der Teilbaum jeder Führungskraft wird an der **nächsten Führungskraft abgeschnitten** ([BackofficeDashboardService.php:627-659](app/Services/Backoffice/BackofficeDashboardService.php#L627-L659), `continue` bei Leader-Kind), damit nichts doppelt zählt und die Summen additiv sind (Kopfzeile „464" = exakte Gesamtsumme).
|
||||
|
||||
Nachgerechnet ohne Abschneiden (DB heute): **Rosa 82 Teamabos + 201 Teamkundenabos** (Kundin: 81/200 — Stand ihres Tests), **Nina exakt 21 + 52** wie von der Kundin angegeben. Die Kundin hat also die komplette Downline gezählt — ihre Erwartung widerspricht der eigenen Entscheidung F9 („an der nächsten Führungskraft abschneiden", siehe `entwicklungskonzept.md`, Entscheidungstabelle).
|
||||
|
||||
**Konsequenz der Umstellung, die die Kundin kennen sollte:** Zählt jede Führungskraft ihre komplette Downline, werden Abos **mehrfach** gezählt (Ninas 73 Abos stecken dann auch komplett in Rosas 283). Die Kopf-Summe „464" und die Spaltensummen sind dann **keine** echten Gesamtwerte mehr.
|
||||
|
||||
**Empfehlung:**
|
||||
1. Umstellung auf komplette Downline ist technisch klein (das `continue` in `teamIdsPerLeader()` entfernen bzw. Schalter einbauen); Performance unkritisch (eine gemeinsame Sponsor-Map, BFS in memory).
|
||||
2. **Vorher kurz an die Kundin:** „Dann zählen Abos bei mehreren Führungskräften gleichzeitig — die Gesamtsumme oben rechnen wir dann weiterhin ohne Doppelzählung. Okay?" (Kopfzeile auf Basis der Gesamtmenge, Zeilen mit kompletter Downline.)
|
||||
3. Optional beide Sichten: Spalten „Team (direkt)" und „Team (gesamt)" — dann bleibt die additive Sicht erhalten.
|
||||
|
||||
++ wir sitzen Option drei, um dass wir beides zeigen und kurz erklären. Das ist relativ komplex und vermutlich wohl nicht verständlich. Von daher würde ich erst mal beides zeigen und dann klären, was dein sinnvoller ist..
|
||||
|
||||
**Umgesetzt (2026-07-24, Option 3 — beide Sichten):** `leaderAboOverview()` liefert je Führungskraft jetzt zusätzlich `team_abo_count_total`/`team_customer_abo_count_total` (komplette Downline, ohne Abschneiden) neben den bisherigen „direkt"-Werten. Die Tabelle zeigt beide Spaltenpaare — „Teamabos/Team-Kundenabos (direkt)" und „(gesamt)" — mit Tooltips; die **Gesamt-Spalte** rechnet mit der kompletten Downline (entspricht der Zählung der Kundin: eigenes Abo + Kundenabos + Teamabos gesamt + Team-Kundenabos gesamt), sortiert danach. Die **Kopf-Summe** bleibt bewusst überschneidungsfrei („direkt"), eine Fußnote unter der Tabelle erklärt den Unterschied. Test: „liefert je Fuehrungskraft zusaetzlich die Teamzahlen der kompletten Downline". → Nach Livegang mit der Kundin klären, ob beide Sichten bleiben oder eine reicht.
|
||||
|
||||
## 5. Statistik 97.453 ≠ Struktur 92.211 („theoretisch gleich")
|
||||
|
||||
**Befund: Beide Zahlen sind korrekt — die Differenz sind Punkte, die nicht payline-wirksam sind.**
|
||||
|
||||
- Die Statistik-Übersicht summiert je Linie `own_points + external_points` aus `user_sales_volumes` ([BackofficeDashboardService.php:437-464](app/Services/Backoffice/BackofficeDashboardService.php#L437-L464)) — also **alle** Umsatzpunkte der Team-Mitglieder.
|
||||
- Die Struktur-/Provisionsansicht (PP-Tabelle) summiert je Ebene `month_TP_points + month_shop_points` ([TreeCalcBotOptimized.php:279-300](app/Services/BusinessPlan/TreeCalcBotOptimized.php#L279-L300), [UserSalesVolume::getPointsTPSum()](app/Models/UserSalesVolume.php)) — nur die **Teampunkte (TP)**, die in die Payline der Upline fließen.
|
||||
- Der Unterschied entsteht in [SalesPointsVolume.php:56-64](app/Services/BusinessPlan/SalesPointsVolume.php#L56-L64): Einträge mit `status_points = 2` zählen **nur als KP (Karrierepunkte des Beraters), nicht als TP**.
|
||||
|
||||
Nachgerechnet für das Team von 454, Juli 2026 (heute): Statistik-Summe **97.769**, TP-Summe **92.569**, Differenz **5.200** = **19 Neupartner-Einschreibungen** (Status 5, je 200/400 Punkte = 5.000) + **1 Gutschrift** (200 Punkte), alle mit `status_points=2`. Die Screenshot-Werte (97.453/92.211, Differenz 5.242) sind derselbe Effekt zum Testzeitpunkt.
|
||||
|
||||
**Empfehlung:** Kein Fix nötig. In der Statistik-Übersicht eine Fußnote/Tooltip ergänzen: „enthält auch Punkte, die nicht in die Payline fließen (z. B. Neupartner-Einschreibungspunkte)" — optional als eigene Aufschlüsselungszeile (Datenbasis vorhanden: `status_points=2`-Summe je Linie).
|
||||
|
||||
++ Umsetzung wie in der Empfehlung.
|
||||
|
||||
**Umgesetzt (2026-07-24):** Neues SQL-Fragment `SalesVolumePoints::nonPaylinePoints()` (status_points = 2) + Kennzahl `non_payline_points` im Overview/Snapshot. Unter der Linien-Tabelle erscheint eine Fußnote: „In den Gesamtpunkten enthalten: X Punkte, die nicht in die Payline fließen (z. B. Neupartner-Einschreibungspunkte) …" (nur wenn > 0; alte Snapshots ohne den Wert zeigen nichts). Test: „summiert Punkte ohne Payline-Wirkung (status_points 2) als eigene Kennzahl".
|
||||
|
||||
## 6. Team-Abo-Seite: „Was bedeuten diese 42.628 Abo-Punkte?"
|
||||
|
||||
**Befund:** Die Spalte „Punkte" in „Abos nach Ebenen" ist der **Soll-Bestand pro Lieferzyklus**: Summe `getTotalPoints()` (Produktpunkte × Menge) aller **aktiven** Abos (Eigen- + Kundenabos) je Ebene — über **alle 10 Ebenen** des Teams ([TeamAboOverviewService.php:282-326](app/Services/Abo/TeamAboOverviewService.php#L282-L326)). Es ist also **nicht** der abgerechnete Umsatz, sondern „was alle Team-Abos pro Zyklus bringen würden".
|
||||
|
||||
Zur Einordnung der Karte darüber: „Abo-Umsatz in der Payline" (Soll 38.375 / Ist 37.912) ist dieselbe Soll-Logik, aber nur für die **Payline** (6 Ebenen), plus Ist = im Monat abgerechnete Abo-Punkte ([PaylineForecastService.php:75-86](app/Services/Abo/PaylineForecastService.php#L75-L86)). 42.628 > 38.375, weil das Team 10 Ebenen hat, die Payline nur 6.
|
||||
|
||||
Der Wunsch „Zahlen, die zum Stichtag 25. im aktuellen Monat abgerechnet werden" ist auf dem Home-Dashboard durch die **Prognose-Kacheln 5./10./20./25.** (B10) bereits abgedeckt.
|
||||
|
||||
**Empfehlung:**
|
||||
1. Spaltenüberschrift präzisieren: „Punkte pro Lieferzyklus (Bestand)".
|
||||
2. **Ist-Spalte je Ebene** ergänzen („im Juli abgerechnet") — SQL-Fragment `SalesVolumePoints::aboPoints()` existiert bereits, nur je Ebenen-User-Menge gruppieren.
|
||||
3. Optional dritte Spalte „davon bis zum 25. erwartet" (Logik aus `forecastDays()` wiederverwendbar).
|
||||
|
||||
++ Umsetzung wie in einer Empfehlung alle drei Punkte entsprechend umsetzen.
|
||||
|
||||
**Umgesetzt (2026-07-24):** Die Ebenen-Tabelle hat jetzt drei Punkte-Spalten: „Punkte pro Lieferung (Bestand)" (bisherige Spalte, präzisiert), „abgerechnet ({Monat})" (`abo_points_billed`, gleiche Zählweise wie der Payline-Ist-Wert) und „bis zum 25. erwartet" (`abo_points_expected` = abgerechnet + offene Ausführungen mit `next_date` bis zum 25.; nur im laufenden Monat, sonst = abgerechnet) — inkl. Summenzeile und Tooltip. Sprachkeys in de/en/es/fr. Test: „liefert je Ebene abgerechnete Abo-Punkte und die Prognose bis zum letzten Liefertag".
|
||||
|
||||
## 7. Payline: Anzahl der Abos ausweisen
|
||||
|
||||
**Feedback:** „Wieviel ABOs gibt es in der Payline? z. B. 205 Teampartnerabos + 302 Teamkundenabos (6 Ebenen)."
|
||||
|
||||
**Befund:** Die Karte zeigt bisher nur „Payline = 6 Ebenen · 504 Berater". Die Zähler sind trivial ermittelbar (gleiche Filter wie `aboTargetPoints()`, [PaylineForecastService.php:164-183](app/Services/Abo/PaylineForecastService.php#L164-L183)). Heute für User 454: 6 Ebenen, 502 Berater, **129 Teampartner-Abos + 340 Team-Kundenabos** (die Beispielzahlen der Kundin waren Schätzungen).
|
||||
|
||||
**Empfehlung:** `aboTurnover()` um `team_abo_count`/`team_customer_abo_count` erweitern und in der Kartenunterzeile anzeigen: „6 Ebenen · 502 Berater · 129 Teamabos + 340 Kundenabos". Aufwand klein, Test in `PaylineForecastServiceTest` ergänzen.
|
||||
|
||||
++ Umsetzung wie in einer Empfehlung
|
||||
|
||||
**Umgesetzt (2026-07-24):** `aboTurnover()` liefert zusätzlich `team_abo_count`/`team_customer_abo_count` (gleiche Filter wie das Soll); die Karten-Unterzeile zeigt jetzt z. B. „Payline = 6 Ebenen · 502 Berater — 129 Teamabos + 340 Kundenabos in der Payline" (Sprachkey `abo.payline_abo_counts`, de/en/es/fr). Test in `PaylineForecastServiceTest` erweitert.
|
||||
|
||||
## 8. Live-Rangliste Montenegro: Nummerierung
|
||||
|
||||
**Feedback (berechtigt):** Gewinner müssen 1, 2, 3, … nummeriert sein; wer mehr Punkte hat, aber die Mindestqualifikation nicht erfüllt, wird **darunter** gereiht (Beispiel Tanja: meiste Punkte, nicht qualifiziert → Platz 5, nicht Platz 1).
|
||||
|
||||
**Befund/Ursache:** Der gespeicherte Rang ist ein **reines Punkte-Ranking über alle Teilnehmer** ([IncentiveTracker.php:320-341](app/Services/Incentive/IncentiveTracker.php#L320-L341), Qualifikation spielt keine Rolle). Die **Anzeige** sortiert aber zuerst nach `is_qualified DESC` ([IncentiveParticipant.php:126-143](app/Models/IncentiveParticipant.php#L126-L143)). Ergebnis: Qualifizierte stehen oben, tragen aber ihre globalen Punkte-Ränge (4, 7, 9), darunter stehen Rang 1, 2, 5 — genau die gemeldete Verwirrung.
|
||||
|
||||
**Zusätzliche Brisanz:** `isWinner` prüft `rank <= max_winners` ([IncentiveParticipant.php:519-526](app/Models/IncentiveParticipant.php#L519-L526)). Mit dem heutigen Punkte-Rang könnte ein qualifizierter Berater mit globalem Punkte-Rang 21+ **fälschlich kein Gewinner** sein, obwohl weniger als 20 qualifiziert sind. Die Sortier-Korrektur repariert also auch die Gewinner-Logik.
|
||||
|
||||
**Empfehlung:** `updateRanking()` auf `ORDER BY is_qualified DESC, total_points DESC` umstellen (Tie-Break unverändert) — dann ist der gespeicherte Rang identisch mit der Anzeigereihenfolge: Qualifizierte zuerst (1..n), Nicht-Qualifizierte dahinter; „springt nach oben", sobald die Qualifikation erreicht ist. **Vorher prüfen:** Wo wird `rank` noch verwendet (Mails/Benachrichtigungen bei Rangänderung, `withRankingActivity()`)? Rangsprünge durch die Umstellung könnten einmalig Benachrichtigungen auslösen — ggf. Benachrichtigung für den Umstellungs-Lauf unterdrücken. Test ergänzen (qualifiziert schlägt Punkte).
|
||||
|
||||
++ Umsetzung wie in einer Empfehlung
|
||||
|
||||
**Umgesetzt (2026-07-24):** `IncentiveTracker::updateRanking()` sortiert jetzt `is_qualified DESC → total_points DESC → Terms-Tie-Break`; der gespeicherte Rang entspricht damit exakt der Anzeigereihenfolge (Qualifizierte 1..n, danach die Nicht-Qualifizierten; wer sich qualifiziert, springt hoch — genau das Kundin-Beispiel „Tanja → Platz 5"). Nebenwirkungen geprüft: `is_qualified` wird vor jedem Ranking-Lauf frisch berechnet, es existiert **kein** Rang-Benachrichtigungsmechanismus (keine Mail-Flut möglich), und `isWinner (rank <= max_winners)` wird durch den Fix erst korrekt (vorher konnte ein Qualifizierter mit Punkte-Rang 21+ fälschlich leer ausgehen). Neuer Test `Incentive/IncentiveRankingTest` (4 Fälle); ein vorbestehend roter Unit-Test (Reflection auf falsche Klasse) wurde mitrepariert.
|
||||
|
||||
## 9. Porto Deutschland: Abo 19,90 vs. Einzelbestellung 16,72
|
||||
|
||||
**Befund: Es gibt keinen echten Preisunterschied — es ist derselbe Versandsatz, einmal brutto und einmal netto angezeigt.** 16,72 × 1,19 = 19,90.
|
||||
|
||||
- Konfiguration: `shipping_prices.price_comp` (Teampartner-Versand, **brutto**): DE **19,90 €**, AT **30,00 €**, jeweils `tax_rate` 19 %.
|
||||
- Der **Abo-Checkout** zeigt für steuerpflichtige DE-Teampartner den **Brutto**-Versand: [_order_combined_summary.blade.php:31](resources/views/admin/abo/_order_combined_summary.blade.php#L31), [_order_abo_show.blade.php:93](resources/views/admin/abo/_order_abo_show.blade.php#L93) (`$taxFree ? shippingNet() : shipping()`), [portal/abo/_create_check.blade.php:158](resources/views/portal/abo/_create_check.blade.php#L158) sogar fest brutto.
|
||||
- Die **Einzelbestellungs-Übersicht** ist eine Netto-Aufstellung und zeigt **netto**: [api_order_list_total.blade.php:57](resources/views/user/shop/sales/api_order_list_total.blade.php#L57) → 19,90 / 1,19 = **16,72 €**.
|
||||
- **Österreich:** Teampartner mit gültiger USt-ID ist Reverse-Charge-steuerfrei (`user_tax_free`, [UserService.php:103-137](app/Services/UserService.php#L103-L137)) → auch der Abo-Zweig zeigt netto: 30,00 / 1,19 = **25,21 €** — deshalb dort „passt es".
|
||||
|
||||
Die tatsächlich berechneten Gesamtbeträge sind konsistent (netto + MwSt); es ist eine **Anzeige-Inkonsistenz**, kein Abrechnungsfehler.
|
||||
|
||||
**Empfehlung:** Porto-Ausweis vereinheitlichen — im Abo-Checkout in der Netto-Aufstellung den **Netto**-Versand zeigen (bzw. „19,90 € inkl. MwSt" kennzeichnen), damit Abo und Einzelbestellung vergleichbar sind. Nebenbefund: [Yard.php:323](app/Services/Yard.php#L323) berechnet `shipping_tax` falsch (liefert den Nettowert statt des Steueranteils) — mit fixen.
|
||||
|
||||
++ Umsetzung wie in einer Empfehlung, Mit einer Kennzeichnung, denn wichtig ist. Bei MU Preise müssen wir immer den Bruttowert anzeigen. Bei den Beratern im System können wir den Nettowert anzeigen. Das ist leider nun mal so, dass die Berater als selbstständige im netto handeln und alles, was nach außen geht, an Kunden brutto zu behandeln ist. Das müsste einmal geprüft werden, dass das einheitlich ist und dann bitte netto oder inklusive Mehrwertsteuer kennzeichnen.
|
||||
|
||||
**Umgesetzt (2026-07-24) nach der Regel „Berater netto (gekennzeichnet), Endkunden brutto (gekennzeichnet)":**
|
||||
- Wichtiger Befund: `_order_combined_summary` und `_order_abo_show` werden **auch im Kundenportal** eingebunden. Deshalb neues View-Flag `portal_view` (Default = Berater), gesetzt von den Portal-Aufrufern (`Portal/AboController`, `portal/abo/my_abo`).
|
||||
- Berater-Ansichten (Abo-Detail Berater/Admin, Einzelbestellungs-Übersicht): Versand **netto** + Kennzeichnung „(netto)" (Key `order.net`, existierte). Die Netto-Summenzeilen passen jetzt rechnerisch zur Porto-Zeile.
|
||||
- Kundenportal (Abo anlegen/ansehen): Versand **brutto** + „(inkl. MwSt.)" (neuer Key `order.incl_VAT`, de/en/es/fr); bei Reverse-Charge/steuerfrei automatisch netto + „(netto)".
|
||||
- Nebenbefund gefixt: `Yard::shipping_tax` berechnete den Nettowert statt des Steueranteils (jetzt brutto − netto); alle anderen `shipping_tax`-Verwendungen (HomepartyCart, ShopApiOrderCart, Correction) waren bereits korrekt und blieben unverändert. Tests: `YardShippingTaxTest` (neu) + 2 Rendering-Tests in `AboOneTimeViewTest`.
|
||||
- **Nachtrag (gleicher Tag):** Drei weitere Versandkosten-Anzeigen ergänzt, die in der ersten Runde fehlten (dort waren die Kundin-Screenshots entstanden): CRM-Warenkorb der Berater [user/order/yard_view_form.blade.php](resources/views/user/order/yard_view_form.blade.php) → „(netto)"; Shop-Warenkorb [web/templates/card.blade.php](resources/views/web/templates/card.blade.php) und Shop-Checkout [web/templates/checkout.blade.php](resources/views/web/templates/checkout.blade.php) → „(inkl. MwSt.)", bei Reverse-Charge/steuerfrei netto + „(netto)" (dort zeigte die Porto-Zeile zuvor sogar brutto über einer Netto-Summenzeile). Nicht angefasst: Homeparty-/Sammelbestellungs- und PDF-/Mail-Ansichten (eigene Belegformate).
|
||||
- **Für die Kundin:** Es gab nie einen Preisunterschied — DE-Teampartner-Porto ist 19,90 € brutto = 16,72 € netto; jetzt ist überall gekennzeichnet, welcher Wert gezeigt wird.
|
||||
|
||||
## 10. Struktur-Ansicht: Symbole „A" (eigenes Abo) und „(n)" (Kundenabos)
|
||||
|
||||
**Feedback (neue Anforderung):** In der Struktur je Teampartner zwei Kennzeichen: grünes **A** = hat eigenes Abo, rotes **(8)** = Anzahl eigener Kundenabos.
|
||||
|
||||
**Einschätzung:** Machbar, Datenbasis vorhanden (aktives `me`-Abo je `user_id`, Kundenabo-Zählung je `member_id` — gleiche Queries wie in der Statistik). Umsetzungsort: Struktur-/Tree-Rendering ([TeamController.php](app/Http/Controllers/User/TeamController.php), `TreeHtmlRenderer`). Wichtig: Zählungen **gebatcht** für alle sichtbaren Knoten laden (zwei Group-by-Queries), nicht pro Knoten. Aufwand mittel (inkl. Badge-Styling + Test). Vorschlag: gleiche Zählweise wie Top-10 (aktiver Bestand, ohne gelöschte) — dann sind Struktur und Statistik konsistent.
|
||||
|
||||
++ So umsetzen
|
||||
|
||||
**Umgesetzt (2026-07-24):** Badges im `TreeHtmlRenderer` (Struktur-Ansicht, `TeamController::structure()`): grünes „A"-Badge bei eigenem aktivem Abo, rotes „(n)"-Badge mit der Anzahl aktiver eigener Kundenabos (nur bei n > 0), direkt neben dem Karriere-Level. Zählweise identisch zur Statistik (`activeAbo`, ohne gelöschte); Daten werden für den ganzen Baum mit zwei gebatchten Queries geladen (keine Query pro Knoten). Tooltips über Sprachkeys `team.badge_own_abo`/`team.badge_customer_abos` (de/en/es/fr). Neuer Test `TeamStructureAboBadgesTest` (5 Fälle).
|
||||
|
||||
---
|
||||
|
||||
## Rückfragen an die Kundin — Stand nach Umsetzung
|
||||
|
||||
1. ~~Führungskräfte direkt vs. gesamt~~ → **entschieden: beide Sichten** (Option 3, umgesetzt). Nach Livegang klären, ob beide Spaltenpaare bleiben.
|
||||
2. ~~Top-10-Umsatz-Sortierung~~ → **entschieden: nach Ist** (umgesetzt).
|
||||
3. ~~Gelöschte aktive Abos~~ → **beantwortet:** Löschungen waren beabsichtigt (Abos wurden wegen Zahlungsproblemen neu angelegt). Fix: Softdelete setzt jetzt `active=0` (Model-Hook) + Datenbereinigung per Migration.
|
||||
4. **Neu/offen (F12 aus dem ursprünglichen Konzept, weiterhin unbeantwortet):** Zählen eigene Abos/Kundenabos in der Payline-Prognose mit? Aktuell ja (`PaylineForecastService::INCLUDE_OWN_ABOS = true`).
|
||||
|
||||
## Anhang: Verifikationsdaten (DB-Stand 2026-07-24, Team von User 454)
|
||||
|
||||
| Beraterin | Top-10 (aktiver Bestand) | davon-Rechnung Montenegro |
|
||||
|---|---|---|
|
||||
| Tanja Longerich (909) | 13 | 17 = 13 + 1 Eigenabo + 2 gelöschte + 1 storniertes |
|
||||
| Rosa M. Assenbaum (456) | 13 | 15 = 13 + 1 Eigenabo + 2 gelöschte − 1 vor Zeitraum aktiviert |
|
||||
| Monika Kunz (1218) | 13 | 16 = 13 + 1 Eigenabo + 2 gelöschte |
|
||||
| Melanie Hoffmann-G. (1143) | 13 | 15 = 13 + 1 Eigenabo + 1 gelöschtes |
|
||||
| Claudia Lattner (454) | 7 (fehlt in Liste, Punkt 1) | 8 = 7 + 1 Eigenabo |
|
||||
|
||||
- Statistik-Linien-Summe heute: 97.769; TP-Summe (Struktur): 92.569; Differenz 5.200 = 19 Neupartner-Einschreibungen (5.000 P.) + 1 Gutschrift (200 P.), alle `status_points=2`.
|
||||
- Führungskräfte komplette Downline heute: Rosa 82 Teamabos / 201 Teamkundenabos (Kundin: 81/200), Nina 21 / 52 (exakt wie Kundin).
|
||||
- Payline 454 heute: 6 Ebenen, 502 Berater, 129 Teamabos, 340 Teamkundenabos.
|
||||
- Montenegro-Incentive: `incentives` id 2, Qualifikation 01.04.–31.07.2026, min. 4 Partner / 6 Abos, max. 20 Gewinner.
|
||||
|
||||
## Anhang: Geänderte Dateien (Umsetzung 2026-07-24)
|
||||
|
||||
**Services/Models:** `BackofficeDashboardService` (ich-Aufnahme, Ist-Sortierung, direkt+gesamt-Teamzahlen, `nonPaylinePointsSum`), `SalesVolumePoints::nonPaylinePoints()`, `TeamAboOverviewService` (`abo_points_billed`/`abo_points_expected`), `PaylineForecastService` (Abo-Zähler), `UserAbo` (deleting-Hook), `IncentiveTracker::updateRanking()`, `Yard` (shipping_tax), `TreeHtmlRenderer` (Abo-Badges), `Portal/AboController` (`portal_view`).
|
||||
|
||||
**Views:** `user/backoffice/statistics/index.blade.php`, `user/team/abos.blade.php`, `admin/abo/_order_combined_summary.blade.php`, `admin/abo/_order_abo_show.blade.php`, `portal/abo/_create_check.blade.php`, `portal/abo/my_abo.blade.php`, `user/shop/sales/api_order_list_total.blade.php`.
|
||||
|
||||
**Sprachdateien:** `abo.php` (payline_abo_counts, abo_points_per_cycle/billed/expected + hint), `order.php` (incl_VAT), `team.php` (badge_own_abo, badge_customer_abos) — jeweils de/en/es/fr.
|
||||
|
||||
**Migration:** `2026_07_24_120000_deactivate_soft_deleted_user_abos` (Datenbereinigung gelöschte-aber-aktive Abos).
|
||||
|
||||
**Tests:** neu `Incentive/IncentiveRankingTest`, `TeamStructureAboBadgesTest`, `YardShippingTaxTest`; erweitert `BackofficeStatisticsListsTest`, `TeamAboOverviewTest`, `PaylineForecastServiceTest`, `AboAdminDeleteTest`, `AboOneTimeViewTest`, `Unit/Incentive/IncentiveTrackerTest`.
|
||||
116
dev/2026-07-22/neuerungen-fuer-berater.md
Normal file
116
dev/2026-07-22/neuerungen-fuer-berater.md
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
# Was ist neu? – Übersicht der Neuerungen (Juli 2026)
|
||||
|
||||
Diese Übersicht erklärt alle Neuerungen, die auf **my.mivita.care** freigeschaltet wurden – mit direkten Links, damit du sofort siehst, wo du klicken musst.
|
||||
|
||||
**Kurzüberblick:**
|
||||
|
||||
| Neuerung | Wo? | Für wen? |
|
||||
| --- | --- | --- |
|
||||
| Abo pausieren | [Mein Abo](https://my.mivita.care/user/abos/me) | Alle Berater mit Abo |
|
||||
| Neues Abo nach Kündigung möglich | [Mein Abo](https://my.mivita.care/user/abos/me) | Alle Berater |
|
||||
| Provision „Stand heute" | [Dashboard](https://my.mivita.care/home) | Alle Berater |
|
||||
| Payline-Prognose (Liefertage) | [Dashboard](https://my.mivita.care/home) | Alle Berater |
|
||||
| Payline-Abo-Umsatz Soll/Ist | [Team-Abos](https://my.mivita.care/user/abos/team/show) | Alle Berater |
|
||||
| Statistik: aktuelle Abo-Bestände, Neupartner-Liste, Top-10, Führungskräfte-Zahlen u. v. m. | [Statistik](https://my.mivita.care/user/backoffice/statistics) | VIP-Berater |
|
||||
|
||||
---
|
||||
|
||||
## 1. Abo pausieren – einen Monat aussetzen
|
||||
|
||||
**Wo:** [Mein Abo](https://my.mivita.care/user/abos/me) → Abo öffnen → Button **„Abo bearbeiten"**
|
||||
|
||||
Du kannst dein Abo jetzt selbst um **einen Monat pausieren**. Die nächste Lieferung wird dabei automatisch um einen Monat nach hinten geschoben – dein Abo bleibt bestehen und läuft danach ganz normal weiter.
|
||||
|
||||
So funktioniert es:
|
||||
|
||||
1. Öffne [Mein Abo](https://my.mivita.care/user/abos/me) und klicke auf **„Abo bearbeiten"**.
|
||||
2. Im Fenster findest du (unter dem Liefertag) den Bereich **Pause** mit dem Button **„Abo pausieren"**.
|
||||
3. Nach dem Bestätigen siehst du sofort das neue Datum der nächsten Lieferung.
|
||||
|
||||
Die Spielregeln:
|
||||
|
||||
- Eine Pause ist möglich, sobald dein Abo **6 bezahlte Lieferungen** erhalten hat (Mindestlaufzeit).
|
||||
- Du kannst **einmal alle 6 Monate** pausieren. Ist die Pause gerade nicht möglich, zeigt dir das Fenster an, **ab wann** du wieder pausieren kannst.
|
||||
- Kurz vor dem nächsten Liefertermin (wenn die Bestellung bereits in Vorbereitung ist) ist keine Pause mehr möglich – dann greift sie ab der übernächsten Lieferung.
|
||||
|
||||
Das Bearbeiten-Fenster wurde dabei auch aufgeräumt: oben der **Liefertag**, darunter die **Pause**, und ganz unten – bewusst dezent – die Kündigung.
|
||||
|
||||
## 2. Kündigung blockiert kein neues Abo mehr
|
||||
|
||||
Bisher konnte es passieren, dass nach einer Abo-Kündigung **kein neues Abo** angelegt werden konnte, weil das alte, gekündigte Abo das System blockiert hat. Das ist behoben: Ein gekündigtes oder ausgelaufenes Abo steht einem **Neustart** nicht mehr im Weg. Wer sein Abo beendet hat, kann also jederzeit wieder ein neues abschließen.
|
||||
|
||||
## 3. Dashboard: Provision „Stand heute" 💶
|
||||
|
||||
**Wo:** [Dashboard (Startseite)](https://my.mivita.care/home) → Karte „Monatsstatistik"
|
||||
|
||||
Im Umsatz-Bereich der Monatsstatistik gibt es eine neue Kachel: **deine Provision zum heutigen Stand** – in Euro, für jeden Berater sichtbar. Sie enthält alle Bestandteile: Payline-Provision, Shop-Provision und Wachstumsbonus.
|
||||
|
||||
Wichtig zu wissen:
|
||||
|
||||
- Im **laufenden Monat** trägt die Kachel ein **„Live"**-Zeichen: Der Wert ist eine Momentaufnahme und wird ca. alle **6 Stunden** aktualisiert. Er ist **vorläufig** – verbindlich wird die Provision erst mit dem Monatsabschluss.
|
||||
- Bei **abgeschlossenen Monaten** siehst du automatisch die endgültig abgerechnete Provision (ohne „Live"-Zeichen).
|
||||
|
||||
## 4. Dashboard: Payline-Prognose zu den Liefertagen 📈
|
||||
|
||||
**Wo:** [Dashboard (Startseite)](https://my.mivita.care/home) → Karte „Monatsstatistik", unterhalb der Kacheln
|
||||
|
||||
Für den laufenden Monat siehst du jetzt eine **Vorschau auf die Abo-Liefertage** (5., 10., 20., 25.): Für jeden noch anstehenden Liefertag wird angezeigt, wie viele Abo-Punkte aus deiner Payline dazukommen und wie hoch dein Punktestand **voraussichtlich insgesamt** danach ist. Bereits vergangene Liefertage sind als „erledigt" markiert.
|
||||
|
||||
So erkennst du früh im Monat, wo du am Monatsende ungefähr landen wirst – z. B. ob die nächste Qualifikationsstufe erreichbar ist.
|
||||
|
||||
## 5. Team-Abos: Payline-Abo-Umsatz Soll und Ist
|
||||
|
||||
**Wo:** [Team-Abos](https://my.mivita.care/user/abos/team/show)
|
||||
|
||||
Auf der Team-Abo-Seite gibt es eine neue Karte mit dem **Abo-Umsatz deiner Payline** – also der Ebenen deines Teams, die für deine Provision zählen:
|
||||
|
||||
- **Soll:** die Punkte, die die aktiven Abos deiner Payline in diesem Monat planmäßig bringen.
|
||||
- **Ist:** die Punkte, die davon **bereits bestellt und bezahlt** wurden.
|
||||
|
||||
Darunter steht, wie viele Ebenen und Teampartner aktuell zu deiner Payline gehören.
|
||||
|
||||
---
|
||||
|
||||
## Neuerungen in der Statistik (nur für VIP-Berater)
|
||||
|
||||
**Wo:** [Statistik](https://my.mivita.care/user/backoffice/statistics)
|
||||
|
||||
### 6. Abo-Bestände immer aktuell
|
||||
|
||||
Die Abo-Zahlen (Teamabos, Kundenabos, Führungskräfte-Abos, Top-Kundenabos) zeigen jetzt **immer den aktuellen Bestand** – auch wenn du einen bereits abgeschlossenen Monat auswählst. Vorher wurden bei alten Monaten eingefrorene Zahlen angezeigt; jetzt siehst du den Bestand von heute (erkennbar am Hinweis „aktueller Stand").
|
||||
|
||||
### 7. Provisions-Karte in der Übersicht
|
||||
|
||||
Neben „Neupartner" und „1000-Punkte-Shops" gibt es oben eine dritte Karte: **deine Provision** für den gewählten Monat, aufgeschlüsselt nach **Payline, Shop und Wachstumsbonus**. Im laufenden Monat als Live-Wert (siehe Punkt 3), bei alten Monaten der abgerechnete Endstand.
|
||||
|
||||
### 8. Neupartner-Liste mit Sponsor und Kundenpunkten
|
||||
|
||||
Klickst du auf die **Neupartner-Zahl**, öffnet sich jetzt eine Liste der neuen Partner des Monats – inklusive **Sponsor** (wer hat den Partner geworben) und dessen **Kundenpunkten**. Die Liste lässt sich wie gewohnt als **CSV-Datei** herunterladen.
|
||||
|
||||
### 9. „Neu"-Zahlen sind jetzt klickbar
|
||||
|
||||
Überall, wo eine grüne **„+N"-Markierung** neue Abos oder neue Partner anzeigt (auch auf dem [Dashboard](https://my.mivita.care/home)), kannst du jetzt **direkt draufklicken** und landest in der passenden Detail-Liste – gefiltert auf **nur die neuen** Einträge des Monats.
|
||||
|
||||
### 10. 1000-Punkte-Shops: dein eigener Shop zählt mit
|
||||
|
||||
Bei der Kennzahl „1000-Punkte-Shops" wird jetzt auch **dein eigener Shop** mitgezählt, wenn er die 1000 Punkte erreicht hat. In der Detail-Liste ist deine eigene Zeile mit **„(ich)"** markiert.
|
||||
|
||||
### 11. Top-10-Listen: Kundenabos im eigenen Team
|
||||
|
||||
Zwei neue Top-10-Übersichten zeigen, welche Teampartner die meisten **Kundenabos** haben:
|
||||
|
||||
- **Top 10 nach Anzahl** der Kundenabos.
|
||||
- **Top 10 nach Umsatz** – mit **Soll** (was die Abos planmäßig bringen) und **Ist** (was bereits bestellt wurde).
|
||||
|
||||
Gezählt wird jeweils der aktuelle Abo-Bestand im eigenen Team.
|
||||
|
||||
### 12. Führungskräfte-Übersicht mit echten Teamzahlen
|
||||
|
||||
Die Führungskräfte-Tabelle zeigt pro Führungskraft jetzt: **eigene Abos**, **Kundenabos**, **Teamabos**, **Team-Kundenabos** und die **Gesamtsumme**. Dabei zählt jede Führungskraft nur ihr eigenes Team **bis zur nächsten Führungskraft** – nichts wird doppelt gezählt, die Summen gehen sauber auf.
|
||||
|
||||
---
|
||||
|
||||
## Gut zu wissen
|
||||
|
||||
- **„Live"-Werte** (Payline-Punkte, Prognose, Provision im laufenden Monat) werden ca. alle **6 Stunden** neu berechnet – dazwischen kann der Wert also ein paar Stunden alt sein.
|
||||
- Die [Statistik-Seite](https://my.mivita.care/user/backoffice/statistics) ist weiterhin **VIP-Beratern** vorbehalten; Dashboard, Abo-Pause und Team-Abo-Seite gelten für **alle Berater**.
|
||||
89
dev/2026-07-22/todos.md
Normal file
89
dev/2026-07-22/todos.md
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
ABOS
|
||||
|
||||
- Wenn der Kunde pausieren möchte, kann er dies nach 6 Monaten tun und für maximal 1 x in 6 Monaten pausieren,
|
||||
Bei den Abo Einstellungen brauchen wir hier eine Pause Funktion d.h. hier kann der User dann einen entsprechenden ein Monat Pause drücken und die Ausführung verschiebt sich um einen Monat
|
||||
|
||||
- Wenn der Kunde stornieren möchte, kann er das nach der 6. Lieferung monatlich kündigen – entweder über den Berater oder den Kunden-LogIn
|
||||
ist glaube ich schon weitesgehend implementiert bitte noch mal genau prüfen hier machen wir einfach einen Button Abo. Jetzt kündigen dann noch mal eine Abfrage wirklich kündigen und dann wird das Abo entsprechend entfernt. Softdelete
|
||||
|
||||
|
||||
Statisitken
|
||||
|
||||
|
||||
Dashboard:
|
||||
Hier fehlt auch noch: Provision aktuell Stand heute
|
||||
Hier bin ich mir nicht sicher, ob die Zahlen aktuell sind, bei den Abos sieht man nicht die aktuellen Zahlen, wenn man den Monat verändert.
|
||||
|
||||
|
||||
Neupartner im Team
|
||||
Wenn man hier raufklickt, sollte in der Liste auch der Sponsor aufscheinen und ebenfalls die tagesaktuellen Kundenpunkte
|
||||
|
||||
1000- Punkte Shop
|
||||
Muss ich selbst auch Teil der Statistik sein, wenn jem selbst den 1000 Punkte-Shop erreicht hat, sollte diese Person selbst auch in der Liste aufgeführt werden und nicht nur sein Team
|
||||
|
||||
|
||||
|
||||
|
||||
Kundenabos insgesamt (Anzahl)
|
||||
Wir benötigen ebenfalls eine Auflistung der TOP 10 von den Kundenabos insgesamt. (unabhängig vom Incentive)
|
||||
zB Platz 1: Monika Kunz 15 Kundenabos, Platz 2: Tanja Longerich 14 KUndenabos und so absteigend
|
||||
|
||||
Kundenabos insgesamt (Umsatz)
|
||||
Wir benötigen ebenfalls eine Auflistung der TOP 10 von den Kundenabos insgesamt vom Umsatz. (unabhängig vom Incentive)
|
||||
zB Platz 1: Tanja Longerich: 1200 Punkte (Kundenumsatz mit Kundenabos), Platz 2: Peggy Groß 800 Punkte (Kundenumsatz mit Kundenabos)
|
||||
|
||||
Führungskräfte- Statisik – teils erledigt
|
||||
Mittlerweile sieht man die Führugnskräfte ab Vertriebsleiter: wer hat ein eigenes Abo, wer hat weiviel Kundenabos, diese Liste gehört noch ergänzg mit: wieviel Teamabos hat diese Person und wieviele Kundenabos vom Team hat diese Person, und am Ende eine Summe wo alle 4 Kennzahlen zusammengezählt werden
|
||||
|
||||
ABO-Statistik
|
||||
Wurde einiges erweitert – man kann jetzt sehen wieviel Umsatz mit Abos pro Ebene da sind und wiveviel Gesamtumsatz im Abo
|
||||
|
||||
Interessant und wichtig wäre hier noch:
|
||||
Abo-Gesamtumsatz in der Payline
|
||||
|
||||
Auf der Übersichtsliste
|
||||
|
||||
Hier auch noch aktuelle Provision als Kennzahl in der Übersichtsliste
|
||||
Wenn man auf die neuen Abos klickt, sollte man sehen wer diese sind
|
||||
|
||||
|
||||
Auf der Übersichtsliste (HOME) benötigen wir noch folgende Kennzahlen
|
||||
- Voraussichtliche Gesamtumsatz in der Payline am 5. (hier wird der aktuelle Kundenumsatz + Gesamtumsatz der Payline genommen und addiert mit den Abo-Umsätzen Payline Stand heute, die am 5. Dazukommen.)
|
||||
|
||||
- Voraussichtliche Gesamtumsatz in der Payline am 10. (hier wird der aktuelle Kundenumsatz + Gesamtumsatz der Payline genommen und addiert mit den Abo-Umsätzen Stand heute, die am 10. Dazukommen.)
|
||||
|
||||
- Voraussichtliche Gesamtumsatz in der Payline am 20. ((hier wird der aktuelle Kundenumsatz + Gesamtumsatz der Payline genommen und addiert mit den Abo-Umsätzen Stand heute, die am 20. Dazukommen.)
|
||||
|
||||
- Voraussichtliche Gesamtumsatz in der Payline am 25. ((hier wird der aktuelle Kundenumsatz + Gesamtumsatz der Payline genommen und addiert mit den Abo-Umsätzen Stand heute, die am 25. Dazukommen.)
|
||||
|
||||
Das hätte den Vorteil, dass wir Anfang Monat schon wissen, wieviel Umsatz wir in der Payline am 5./10./20./25. Haben werden. Diese Zahl wird aber immer tagesaktuell mit dem aktuellen Kundenumsatz (Abo/nicht Abo, persönliche Bestellungen, neupartnereinschreibungspunkte, etc) und den sonstigen Paylinepunkten (Umsatz ohne Abos) addiert
|
||||
|
||||
|
||||
Führungskräfte-Statistik
|
||||
Pro Führungskraft braucht es folgende Kennzahl
|
||||
Eigenes Abo, Kundenabo, Teamabos, Kundenabos im Team, SUMME Gesamtabos im Team (eigenes Abo + Kundenabos +Teamabos +Kundenabos im Team)
|
||||
|
||||
https://my.mivita.test/user/backoffice/statistics
|
||||
Abos bei den führungskrägfen:
|
||||
|
||||
Hier pro Führungskraft auch noch
|
||||
|
||||
Wieviel Abos im Team
|
||||
Wieviel Kundenabos im Team
|
||||
|
||||
Abos gesamt (eigenes Abo + eigene Kundenabos + teamabos + Kundenabos im team)
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Offene Fragen (Claude, 2026-07-22) — BEANTWORTET
|
||||
|
||||
**Update:** Alle Fragen wurden am 2026-07-22 beantwortet und ins Konzept eingearbeitet — siehe `entwicklungskonzept.md`, Abschnitt „Entscheidungen". Einzig F12 (eigene Abos in der Payline-Prognose) wird als dokumentierter Vorschlag umgesetzt und nach der Umsetzung erneut vorgelegt. Ursprüngliche Fragen:
|
||||
|
||||
1. **Abo-Pause:** Zählen für „nach 6 Monaten" 6 bezahlte Lieferungen (wie bei der Kündigung) oder 6 Kalendermonate? Gilt „1× in 6 Monaten" rollierend?
|
||||
2. **Kündigung „Softdelete":** Empfehlung: Abo auf Status „gekündigt" setzen statt löschen (verschwindet aus aktiven Listen, bleibt für Statistik/Abrechnung erhalten) — okay so? Zudem muss das Setting `abo-min-duration` in Produktion auf 6 stehen (hat aktuell keinen Default).
|
||||
3. **Provision „Stand heute":** Welche Bestandteile (nur Payline-Provision oder auch Shop/Wachstum)? Für alle Berater sichtbar oder nur VIP? Aktualisierung alle ~6 h ausreichend?
|
||||
4. **Abo-Zahlen bei Monatswechsel:** Sollen Abo-Bestandszahlen bei vergangenen Monaten den heutigen Stand zeigen (Empfehlung), während Punkte/Umsätze eingefroren bleiben?
|
||||
5. **Top-10 Kundenabos:** Eigenes Team oder unternehmensweit? Bestand aktiver Abos? Umsatz = Abo-Punkte-Bestand oder abgerechneter Monatsumsatz?
|
||||
6. **Führungskräfte-Statistik:** Wenn eine Führungskraft unter einer anderen hängt — zählt deren Team beim Oberen mit (Überlappung) oder wird an der nächsten Führungskraft abgeschnitten?
|
||||
7. **Payline-Prognose 5./10./20./25.:** Kumuliert (Prognose zum 25. enthält 5./10./20.)? Zählen auch eigene Abos/Kundenabos des Beraters dazu oder nur die Payline-Downline?
|
||||
43
dev/log-errors/errors.md
Normal file
43
dev/log-errors/errors.md
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
#mivita Exception: https://in.mivita.care/portal/my-subscriptions
|
||||
Route [user_edit] not defined. (View: /home/ploi/mivita.care/resources/views/admin/abo/_detail.blade.php) (View: /home/ploi/mivita.care/resources/views/admin/abo/_detail.blade.php)
|
||||
in /home/ploi/mivita.care/vendor/laravel/framework/src/Illuminate/Routing/UrlGenerator.php (line 517)
|
||||
if (! is_null($this->missingNamedRouteResolver) && ! is_null($url = call_user_func($this->missingNamedRouteResolver, $name, $parameters, $absolute))) { return $url; } throw new RouteNotFoundException("Route [{$name}] not defined."); } /** * Get the URL for a given route instance. *
|
||||
|
||||
|
||||
#mivita Exception: https://mivita.care
|
||||
SOAP-ERROR: Parsing WSDL: Couldn't load from 'https://kasapi.kasserver.com/soap/wsdl/KasAuth.wsdl' : failed to load external entity "https://kasapi.kasserver.com/soap/wsdl/KasAuth.wsdl"
|
||||
in /home/ploi/mivita.care/app/Http/Controllers/Api/KasController.php (line 75)
|
||||
$this->checkSession('auth'); try { $SoapLogon = new SoapClient('https://kasapi.kasserver.com/soap/wsdl/KasAuth.wsdl', [ 'cache_wsdl' => WSDL_CACHE_NONE, 'exceptions' => false ]); $this->CredentialToken = $SoapLogon->KasAuth(json_encode(array( 'KasUser' => $this->kas_user,
|
||||
|
||||
|
||||
|
||||
#mivita Exception: https://mivita.shop/checkout
|
||||
Undefined variable $shopping_user (View: /home/ploi/mivita.care/resources/views/web/templates/checkout.blade.php)
|
||||
in /home/ploi/mivita.care/storage/framework/views/d620a70b10aed9766a5ae675e5aaa600.php (line 384)
|
||||
<div class="m-checkout"><?php echo Form::open(['action' => route('checkout.checkout_card_final'), 'class' => 'row clearfix', 'id'=>'checkout_card_final']); ?><?php echo Form::hidden('selected_country', ''); ?><?php echo Form::hidden('is_for', $shopping_user->is_for); ?><?php echo Form::hidden('is_from', $shopping_user->is_from); ?><?php echo Form::hidden('homeparty_id', $shopping_user->homeparty_id); ?>
|
||||
|
||||
|
||||
|
||||
#mivita Exception: https://my.mivita.care/modal/load
|
||||
Class name must be a valid object or a string (View: /home/ploi/mivita.care/resources/views/admin/payment/modal_credit_status.blade.php)
|
||||
in /home/ploi/mivita.care/storage/framework/views/229b6b9c7e2b030b1a72955870e1c7d3.php (line 30)
|
||||
<input type="hidden" name="id" value="<?php echo e($data['id']); ?>"><div class="form-row"><div class="form-group col-12"><label for="status" class="form-label"><?php echo e(__('Status')); ?>*</label><?php echo e(Form::select('status', $UserCredit::getTransStatusType(), $UserCredit->status, array('data-live-search'=>'false', 'class'=>'selectpicker') )); ?></div></div><table class="table user-view-table m-0"><tbody>
|
||||
|
||||
|
||||
#mivita Exception: https://my.mivita.care/storage/file/4654/credit_detail/favicon.ico
|
||||
App\Repositories\CreditRepository::__construct(): Argument #1 ($model) must be of type App\User, null given, called in /home/ploi/mivita.care/app/Http/Controllers/FileController.php on line 248
|
||||
in /home/ploi/mivita.care/app/Repositories/CreditRepository.php (line 21)
|
||||
class CreditRepository extends BaseRepository{ private $user_credit; public function __construct(User $model) { $this->model = $model; } public function create($request = [])
|
||||
|
||||
|
||||
#mivita Exception: https://my.mivita.care/storage/file/4654/credit_detail/pdf
|
||||
App\Repositories\CreditRepository::__construct(): Argument #1 ($model) must be of type App\User, null given, called in /home/ploi/mivita.care/app/Http/Controllers/FileController.php on line 248
|
||||
in /home/ploi/mivita.care/app/Repositories/CreditRepository.php (line 21)
|
||||
class CreditRepository extends BaseRepository{ private $user_credit; public function __construct(User $model) { $this->model = $model; } public function create($request = [])
|
||||
|
||||
|
||||
#mivita Exception: https://my.mivita.care/user/team/members/datatable-optimized?_=1783533898138&columns%5B0%5D%5Bdata%5D=id&columns%5B0%5D%5Bname%5D=id&columns%5B0%5D%5Bsearchable%5D=false&columns%5B0%5D%5Borderable%5D=false&columns%5B0%5D%5Bsearch%5D%5Bvalue%5D=&columns%5B0%5D%5Bsearch%5D%5Bregex%5D=false&columns%5B1%5D%5Bdata%5D=deep&columns%5B1%5D%5Bname%5D=deep&columns%5B1%5D%5Bsearchable%5D=false&columns%5B1%5D%5Borderable%5D=true&columns%5B1%5D%5Bsearch%5D%5Bvalue%5D=&columns%5B1%5D%5Bsearch%5D%5Bregex%5D=false&columns%5B2%5D%5Bdata%5D=user_level&columns%5B2%5D%5Bname%5D=user_level&columns%5B2%5D%5Bsearchable%5D=false&columns%5B2%5D%5Borderable%5D=false&columns%5B2%5D%5Bsearch%5D%5Bvalue%5D=&columns%5B2%5D%5Bsearch%5D%5Bregex%5D=false&columns%5B3%5D%5Bdata%5D=is_qual_kp&columns%5B3%5D%5Bname%5D=is_qual_kp&columns%5B3%5D%5Bsearchable%5D=false&columns%5B3%5D%5Borderable%5D=false&columns%5B3%5D%5Bsearch%5D%5Bvalue%5D=&columns%5B3%5D%5Bsearch%5D%5Bregex%5D=false&columns%5B4%5D%5Bdata%
|
||||
App\Services\BusinessPlan\TreeHelperOptimized::generateQualKPBadgeForUser(): Argument #1 ($user) must be of type App\User, null given, called in /home/ploi/mivita.care/app/Http/Controllers/User/TeamController.php on line 322
|
||||
|
||||
in /home/ploi/mivita.care/app/Services/BusinessPlan/TreeHelperOptimized.php (line 36)
|
||||
} /** * Generiert QualKP Badge für User */ public static function generateQualKPBadgeForUser(User $user, int $month, int $year): string { if (!$user->user_level) { return '-'; }
|
||||
|
|
@ -36,6 +36,7 @@
|
|||
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||
<env name="SESSION_DRIVER" value="array"/>
|
||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||
<env name="VIES_ENABLED" value="false"/>
|
||||
<env name="BASIC_AUTH_ENABLED" value="false"/>
|
||||
<env name="PAYONE_KEY_HASH" value="698fb2555f8b2efc74f60b2121421f45"/>
|
||||
<env name="STRIPE_SECRET" value="sk_test_fake_suite_key"/>
|
||||
|
|
|
|||
|
|
@ -43,6 +43,24 @@ return [
|
|||
'error_change_locked' => 'Änderungen sind nicht mehr möglich. Die nächste Ausführung ist in :days Tagen. Änderungen müssen mindestens :lock Tage vorher erfolgen.',
|
||||
'error_abo_interval_too_soon' => 'Der gewählte Liefertag liegt nur :days Tage entfernt. Bitte wähle einen Liefertag, der mindestens :lock Tage in der Zukunft liegt.',
|
||||
'error_cancel_locked' => 'Eine Kündigung ist nicht mehr möglich. Die nächste Ausführung ist in :days Tagen. Kündigungen müssen mindestens :lock Tage vorher erfolgen.',
|
||||
'abo_copy_pause' => 'Verschiebt die nächste Ausführung um einen Monat nach hinten. Maximal 1x alle :months Monate möglich.',
|
||||
'payline_abo_turnover' => 'Abo-Umsatz in der Payline',
|
||||
'payline_abo_turnover_hint' => 'Soll = Abo-Punkte pro Zyklus aller aktiven Abos deiner Payline (Berater- und Kundenabos). Ist = im laufenden Monat bereits abgerechnete Abo-Punkte der Payline.',
|
||||
'payline_target_label' => 'Soll (Punkte pro Zyklus)',
|
||||
'payline_actual_label' => 'Ist (im Monat abgerechnet)',
|
||||
'payline_hint' => 'Payline = :lines Ebenen · :members Berater',
|
||||
'payline_abo_counts' => ':team_abos Teamabos + :customer_abos Kundenabos in der Payline',
|
||||
'payline_none' => 'Noch keine Payline vorhanden (kein Provisionslevel erreicht).',
|
||||
'abo_points_per_cycle' => 'Punkte pro Lieferung (Bestand)',
|
||||
'abo_points_billed' => 'abgerechnet (:month)',
|
||||
'abo_points_expected' => 'bis zum :day. erwartet',
|
||||
'abo_points_expected_hint' => 'Bereits abgerechnete Abo-Punkte plus die Punkte aller Abos dieser Ebene, deren Lieferung bis zum :day. des Monats noch ansteht.',
|
||||
'pause_abo' => '1 Monat pausieren',
|
||||
'confirm_pause' => 'Möchtest du das Abo wirklich um einen Monat pausieren? Die nächste Lieferung verschiebt sich auf den Folgemonat.',
|
||||
'pause_success' => 'Dein Abo wurde um einen Monat pausiert. Die nächste Ausführung ist am :date.',
|
||||
'error_pause_interval' => 'Das Abo kann maximal 1x alle :months Monate pausiert werden. Die nächste Pause ist ab dem :date möglich.',
|
||||
'error_pause_not_active' => 'Nur aktive Abos können pausiert werden.',
|
||||
'info_pause_available_from' => 'Eine Pause ist maximal 1x alle :months Monate möglich — wieder ab dem :date.',
|
||||
'error_pause_locked' => 'Das Abo kann nicht mehr pausiert werden. Die nächste Ausführung ist in :days Tagen. Pausieren muss mindestens :lock Tage vorher erfolgen.',
|
||||
'error_next_date' => 'Das Datum für die nächste Ausführung nicht korrekt',
|
||||
'checkout_mail_abo_hl' => 'Dein Abo / regelmäßige Lieferung.',
|
||||
|
|
|
|||
|
|
@ -36,6 +36,12 @@ return [
|
|||
'desc_rollback' => 'Auf Ursprung zurückgesetzt',
|
||||
|
||||
// Channels
|
||||
'status_history_title' => 'Status-Änderungen (Pause & Kündigung)',
|
||||
'status_paused' => 'Pausiert',
|
||||
'status_cancelled' => 'Gekündigt',
|
||||
'status_deactivated' => 'Deaktiviert',
|
||||
'status_reactivated' => 'Reaktiviert',
|
||||
'status_shift' => 'Ausführung von :from auf :to verschoben',
|
||||
'channel_admin' => 'Admin',
|
||||
'channel_user_me' => 'Berater',
|
||||
'channel_user_ot' => 'Berater (Kunde)',
|
||||
|
|
|
|||
|
|
@ -48,6 +48,12 @@ return [
|
|||
'until' => 'bis zum',
|
||||
'welcome_back' => 'Willkommen zurück',
|
||||
'your_shop' => 'Dein Shop',
|
||||
'commission_today' => 'Provision (Stand heute)',
|
||||
'payline_forecast_title' => 'Payline-Prognose zum 5. / 10. / 20. / 25.',
|
||||
'payline_forecast_hint' => 'Basis: aktueller Kundenumsatz + Teamumsatz (Payline). Dazu kommen kumuliert die bis zum jeweiligen Termin noch anstehenden Abo-Punkte deiner Payline (inkl. deiner eigenen Abos und Kundenabos). Prognose: Pausen, Kündigungen oder fehlgeschlagene Zahlungen können den Wert verändern.',
|
||||
'forecast_day' => 'Prognose zum :date',
|
||||
'forecast_upcoming' => 'Abo-Punkte offen',
|
||||
'forecast_done' => 'Termin liegt bereits im Ist-Umsatz',
|
||||
'monthly_statistics' => 'Monatsstatistik',
|
||||
'customer_turnover_points' => 'Kunden-Umsatz Punkte',
|
||||
'team_turnover_points' => 'Team-Umsatz Punkte',
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ return [
|
|||
'homeparty_delete' => 'Auszeitparty gelöscht',
|
||||
'VATID_could_not_be_validated' => 'Die UST-ID konnte nicht validiert werden, Eingabe bitte prüfen',
|
||||
'VATID_successfully_entered' => 'UST-ID erfolgreich eingetragen',
|
||||
'VATID_german_not_allowed' => 'Deutsche USt-IDs können hier nicht verwendet werden. Das Reverse-Charge-Verfahren gilt nur für ausländische EU-USt-IDs.',
|
||||
'VATID_no_longer_valid_taxes_applied' => 'Deine USt-ID ist nicht mehr gültig. Die Bestellung wird daher mit Umsatzsteuer berechnet.',
|
||||
'reverse_charge_procedure_and_VATID_deleted' => 'Reverse Charge Verfahren und UST-ID gelöscht',
|
||||
'no_id_card_deposited_please_upload_first' => 'Kein Ausweis hinterlegt, bitte erst hochladen',
|
||||
'no_trade_licence_deposited_please_upload_first' => 'Kein Gewerbeschein hinterlegt, bitte erst hochladen',
|
||||
|
|
@ -38,6 +40,13 @@ return [
|
|||
'account_released' => 'Account freigeschaltet',
|
||||
'cart_product_not_allowed_for_order_type' => 'Der Warenkorb enthält Artikel, die für diese Bestellart nicht vorgesehen sind. Bitte leere den Warenkorb und wähle nur Produkte, die zu dieser Bestellung passen.',
|
||||
'cart_max_weight_reached' => 'Das maximale Versandgewicht ist erreicht. Es können keine weiteren Produkte hinzugefügt werden.',
|
||||
'gdpr_delete_success' => 'DSGVO-Löschung ausgeführt: Alle personenbezogenen Daten wurden unwiderruflich anonymisiert.',
|
||||
'gdpr_delete_customer_success' => 'DSGVO-Löschung ausgeführt: Alle personenbezogenen Daten des Kunden wurden unwiderruflich anonymisiert.',
|
||||
'gdpr_delete_customer_button' => 'Kunde DSGVO-löschen',
|
||||
'gdpr_confirm_checkbox_missing' => 'Bitte bestätige die DSGVO-Löschung über die Checkbox.',
|
||||
'gdpr_error_is_admin' => 'DSGVO-Löschung nicht möglich: Der Account hat Admin-Rechte. Bitte entziehe zuerst die Admin-Rechte.',
|
||||
'gdpr_error_active_abo' => 'DSGVO-Löschung nicht möglich: Es besteht noch ein aktives Abo. Bitte kündige das Abo zuerst.',
|
||||
'gdpr_error_customer_is_member' => 'DSGVO-Löschung nicht möglich: Dieser Kundendatensatz gehört zu einem Berater-Account. Bitte nutze die DSGVO-Löschung in der User-Verwaltung.',
|
||||
];
|
||||
/*
|
||||
{{ __('msg.') }}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ return [
|
|||
'gross' => 'brutto',
|
||||
'gross_price' => 'Preis brutto',
|
||||
'incentives' => 'Incentives',
|
||||
'incl_VAT' => 'inkl. MwSt.',
|
||||
'included_VAT' => 'Enthaltene MwSt',
|
||||
'invoice' => 'Rechnung',
|
||||
'invoice_address' => 'Rechnungsadresse',
|
||||
|
|
|
|||
|
|
@ -70,6 +70,10 @@ return [
|
|||
'in_progress_yellow' => 'In Arbeit (gelb)',
|
||||
'no_level_red' => 'Kein Level (rot)',
|
||||
|
||||
// Abo-Kennzeichen (Struktur-Ansicht)
|
||||
'badge_own_abo' => 'hat ein eigenes Abo',
|
||||
'badge_customer_abos' => '{1} 1 eigenes Kundenabo|[2,*] :count eigene Kundenabos',
|
||||
|
||||
// Performance & Badges
|
||||
'optimized' => 'OPTIMIERT',
|
||||
'standard_monitoring' => 'STANDARD + MONITORING',
|
||||
|
|
|
|||
|
|
@ -169,6 +169,24 @@ return [
|
|||
'error_change_locked' => 'Changes are no longer possible. The next execution is in :days days. Changes must be made at least :lock days in advance.',
|
||||
'error_abo_interval_too_soon' => 'The selected delivery day is only :days days away. Please choose a delivery day at least :lock days in the future.',
|
||||
'error_cancel_locked' => 'Cancellation is no longer possible. The next execution is in :days days. Cancellations must be made at least :lock days in advance.',
|
||||
'abo_copy_pause' => 'Moves the next execution back by one month. Possible at most once every :months months.',
|
||||
'payline_abo_turnover' => 'Subscription turnover in the payline',
|
||||
'payline_abo_turnover_hint' => 'Target = subscription points per cycle of all active subscriptions in your payline (consultant and customer subscriptions). Actual = subscription points of the payline already billed in the current month.',
|
||||
'payline_target_label' => 'Target (points per cycle)',
|
||||
'payline_actual_label' => 'Actual (billed this month)',
|
||||
'payline_hint' => 'Payline = :lines levels · :members consultants',
|
||||
'payline_abo_counts' => ':team_abos team subscriptions + :customer_abos customer subscriptions in the payline',
|
||||
'payline_none' => 'No payline yet (no commission level reached).',
|
||||
'abo_points_per_cycle' => 'Points per delivery (current stock)',
|
||||
'abo_points_billed' => 'billed (:month)',
|
||||
'abo_points_expected' => 'expected by day :day',
|
||||
'abo_points_expected_hint' => 'Subscription points already billed plus the points of all subscriptions on this level whose delivery is still due by day :day of the month.',
|
||||
'pause_abo' => 'Pause for 1 month',
|
||||
'confirm_pause' => 'Do you really want to pause the subscription for one month? The next delivery will move to the following month.',
|
||||
'pause_success' => 'Your subscription has been paused for one month. The next execution is on :date.',
|
||||
'error_pause_interval' => 'The subscription can be paused at most once every :months months. The next pause is possible from :date.',
|
||||
'error_pause_not_active' => 'Only active subscriptions can be paused.',
|
||||
'info_pause_available_from' => 'A pause is possible at most once every :months months — available again from :date.',
|
||||
'error_pause_locked' => 'The subscription can no longer be paused. The next execution is in :days days. Pausing must be done at least :lock days in advance.',
|
||||
'cancel_abo' => 'Cancel subscription',
|
||||
'confirm_cancel' => 'Do you really want to cancel the subscription?',
|
||||
|
|
|
|||
|
|
@ -36,6 +36,12 @@ return [
|
|||
'desc_rollback' => 'Reset to original',
|
||||
|
||||
// Channels
|
||||
'status_history_title' => 'Status changes (pause & cancellation)',
|
||||
'status_paused' => 'Paused',
|
||||
'status_cancelled' => 'Cancelled',
|
||||
'status_deactivated' => 'Deactivated',
|
||||
'status_reactivated' => 'Reactivated',
|
||||
'status_shift' => 'Execution moved from :from to :to',
|
||||
'channel_admin' => 'Admin',
|
||||
'channel_user_me' => 'Consultant',
|
||||
'channel_user_ot' => 'Consultant (Customer)',
|
||||
|
|
|
|||
|
|
@ -47,6 +47,12 @@ return [
|
|||
'until' => 'until',
|
||||
'welcome_back' => 'welcome back',
|
||||
'your_shop' => 'your shop',
|
||||
'commission_today' => 'Commission (as of today)',
|
||||
'payline_forecast_title' => 'Payline forecast for the 5th / 10th / 20th / 25th',
|
||||
'payline_forecast_hint' => 'Basis: current customer turnover + team turnover (payline). Plus, cumulatively, the subscription points of your payline still due up to the respective date (incl. your own subscriptions and customer subscriptions). Forecast: pauses, cancellations or failed payments can change the value.',
|
||||
'forecast_day' => 'Forecast for :date',
|
||||
'forecast_upcoming' => 'subscription points due',
|
||||
'forecast_done' => 'Date already included in actual turnover',
|
||||
'monthly_statistics' => 'Monthly Statistics',
|
||||
'customer_turnover_points' => 'Customer Turnover Points',
|
||||
'team_turnover_points' => 'Team Turnover Points',
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
return [
|
||||
'VATID_could_not_be_validated' => 'The VAT ID could not be validated, please check your entry',
|
||||
'VATID_successfully_entered' => 'VAT ID entered successfully',
|
||||
'VATID_german_not_allowed' => 'German VAT IDs cannot be used here. The reverse charge procedure only applies to foreign EU VAT IDs.',
|
||||
'VATID_no_longer_valid_taxes_applied' => 'Your VAT ID is no longer valid. VAT will therefore be applied to this order.',
|
||||
'abo_deaktivert' => 'subscription option disabled',
|
||||
'account_released' => 'Account activated',
|
||||
'booked_package_has_been_changed' => 'booked package has been changed.',
|
||||
|
|
@ -40,4 +42,11 @@ return [
|
|||
],
|
||||
'cart_product_not_allowed_for_order_type' => 'Your cart contains items that are not allowed for this order type. Please clear the cart and only add products that match this order.',
|
||||
'cart_max_weight_reached' => 'The maximum shipping weight has been reached. No further products can be added.',
|
||||
'gdpr_delete_success' => 'GDPR deletion executed: All personal data has been irreversibly anonymized.',
|
||||
'gdpr_delete_customer_success' => 'GDPR deletion executed: All personal data of the customer has been irreversibly anonymized.',
|
||||
'gdpr_delete_customer_button' => 'GDPR-delete customer',
|
||||
'gdpr_confirm_checkbox_missing' => 'Please confirm the GDPR deletion via the checkbox.',
|
||||
'gdpr_error_is_admin' => 'GDPR deletion not possible: The account has admin rights. Please remove the admin rights first.',
|
||||
'gdpr_error_active_abo' => 'GDPR deletion not possible: There is still an active subscription. Please cancel the subscription first.',
|
||||
'gdpr_error_customer_is_member' => 'GDPR deletion not possible: This customer record belongs to a consultant account. Please use the GDPR deletion in the user administration.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ return [
|
|||
'gross' => 'gross',
|
||||
'gross_price' => 'gross price',
|
||||
'incentives' => 'incentives',
|
||||
'incl_VAT' => 'incl. VAT',
|
||||
'included_VAT' => 'VAT included',
|
||||
'invoice' => 'invoice',
|
||||
'invoice_address' => 'billing address',
|
||||
|
|
|
|||
|
|
@ -68,6 +68,10 @@ return [
|
|||
'in_progress_yellow' => 'In Progress (yellow)',
|
||||
'no_level_red' => 'No Level (red)',
|
||||
|
||||
// Abo badges (structure view)
|
||||
'badge_own_abo' => 'has an own subscription',
|
||||
'badge_customer_abos' => '{1} 1 own customer subscription|[2,*] :count own customer subscriptions',
|
||||
|
||||
// Performance & Badges
|
||||
'optimized' => 'OPTIMIZED',
|
||||
'standard_monitoring' => 'STANDARD + MONITORING',
|
||||
|
|
|
|||
|
|
@ -152,6 +152,24 @@ return [
|
|||
'error_change_locked' => 'Los cambios ya no son posibles. La próxima ejecución es en :days días. Los cambios deben realizarse al menos :lock días antes.',
|
||||
'error_abo_interval_too_soon' => 'El día de entrega seleccionado está a solo :days días. Elija un día de entrega con al menos :lock días de anticipación.',
|
||||
'error_cancel_locked' => 'La cancelación ya no es posible. La próxima ejecución es en :days días. Las cancelaciones deben realizarse al menos :lock días antes.',
|
||||
'abo_copy_pause' => 'Retrasa la próxima ejecución un mes. Posible como máximo 1 vez cada :months meses.',
|
||||
'payline_abo_turnover' => 'Volumen de suscripciones en la payline',
|
||||
'payline_abo_turnover_hint' => 'Objetivo = puntos de suscripción por ciclo de todas las suscripciones activas de tu payline (de asesores y clientes). Real = puntos de suscripción de la payline ya facturados en el mes en curso.',
|
||||
'payline_target_label' => 'Objetivo (puntos por ciclo)',
|
||||
'payline_actual_label' => 'Real (facturado este mes)',
|
||||
'payline_hint' => 'Payline = :lines niveles · :members asesores',
|
||||
'payline_abo_counts' => ':team_abos suscripciones de asesores + :customer_abos suscripciones de clientes en la payline',
|
||||
'payline_none' => 'Aún no hay payline (no se ha alcanzado ningún nivel de comisión).',
|
||||
'abo_points_per_cycle' => 'Puntos por entrega (cartera actual)',
|
||||
'abo_points_billed' => 'facturado (:month)',
|
||||
'abo_points_expected' => 'previsto hasta el día :day',
|
||||
'abo_points_expected_hint' => 'Puntos de suscripción ya facturados más los puntos de todas las suscripciones de este nivel cuya entrega aún está pendiente hasta el día :day del mes.',
|
||||
'pause_abo' => 'Pausar 1 mes',
|
||||
'confirm_pause' => '¿Realmente quieres pausar la suscripción un mes? La próxima entrega se trasladará al mes siguiente.',
|
||||
'pause_success' => 'Tu suscripción se ha pausado un mes. La próxima ejecución es el :date.',
|
||||
'error_pause_interval' => 'La suscripción puede pausarse como máximo 1 vez cada :months meses. La próxima pausa es posible a partir del :date.',
|
||||
'error_pause_not_active' => 'Solo se pueden pausar suscripciones activas.',
|
||||
'info_pause_available_from' => 'Una pausa es posible como máximo 1 vez cada :months meses — de nuevo a partir del :date.',
|
||||
'error_pause_locked' => 'La suscripción ya no puede pausarse. La próxima ejecución es en :days días. La pausa debe realizarse al menos :lock días antes.',
|
||||
'cancel_abo' => 'Cancelar suscripción',
|
||||
'confirm_cancel' => '¿Realmente desea cancelar la suscripción?',
|
||||
|
|
|
|||
|
|
@ -36,6 +36,12 @@ return [
|
|||
'desc_rollback' => 'Restablecido al original',
|
||||
|
||||
// Canales
|
||||
'status_history_title' => 'Cambios de estado (pausa y cancelación)',
|
||||
'status_paused' => 'Pausado',
|
||||
'status_cancelled' => 'Cancelado',
|
||||
'status_deactivated' => 'Desactivado',
|
||||
'status_reactivated' => 'Reactivado',
|
||||
'status_shift' => 'Ejecución trasladada del :from al :to',
|
||||
'channel_admin' => 'Admin',
|
||||
'channel_user_me' => 'Consultor',
|
||||
'channel_user_ot' => 'Consultor (Cliente)',
|
||||
|
|
|
|||
|
|
@ -47,6 +47,12 @@ return [
|
|||
'until' => 'hasta',
|
||||
'welcome_back' => 'bienvenido de nuevo',
|
||||
'your_shop' => 'tu tienda',
|
||||
'commission_today' => 'Comisión (a día de hoy)',
|
||||
'payline_forecast_title' => 'Pronóstico de payline para el 5 / 10 / 20 / 25',
|
||||
'payline_forecast_hint' => 'Base: volumen de clientes actual + volumen del equipo (payline). Más, de forma acumulada, los puntos de suscripción de tu payline pendientes hasta la fecha correspondiente (incl. tus propias suscripciones y las de tus clientes). Pronóstico: pausas, cancelaciones o pagos fallidos pueden cambiar el valor.',
|
||||
'forecast_day' => 'Pronóstico para el :date',
|
||||
'forecast_upcoming' => 'puntos de suscripción pendientes',
|
||||
'forecast_done' => 'Fecha ya incluida en el volumen real',
|
||||
'monthly_statistics' => 'Estadísticas mensuales',
|
||||
'customer_turnover_points' => 'Puntos de venta de clientes',
|
||||
'team_turnover_points' => 'Puntos de venta del equipo',
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
return [
|
||||
'VATID_could_not_be_validated' => 'No se pudo validar el ID de IVA, verifique su entrada',
|
||||
'VATID_successfully_entered' => 'ID de IVA ingresado exitosamente',
|
||||
'VATID_german_not_allowed' => 'Los ID de IVA alemanes no se pueden utilizar aquí. El procedimiento de inversión del sujeto pasivo solo se aplica a ID de IVA extranjeros de la UE.',
|
||||
'VATID_no_longer_valid_taxes_applied' => 'Tu ID de IVA ya no es válido. Por lo tanto, el pedido se calculará con IVA.',
|
||||
'abo_deaktivert' => 'opción de suscripción deshabilitada',
|
||||
'account_released' => 'Cuenta activada',
|
||||
'booked_package_has_been_changed' => 'El paquete reservado ha sido modificado.',
|
||||
|
|
@ -40,4 +42,11 @@ return [
|
|||
'' => 'Su carrito de compras está vacío, agregue productos primero',
|
||||
],
|
||||
'cart_product_not_allowed_for_order_type' => 'El carrito contiene artículos que no están previstos para este tipo de pedido. Vacía el carrito y elige solo productos que correspondan a este pedido.',
|
||||
'gdpr_delete_success' => 'Eliminación RGPD ejecutada: Todos los datos personales han sido anonimizados de forma irreversible.',
|
||||
'gdpr_delete_customer_success' => 'Eliminación RGPD ejecutada: Todos los datos personales del cliente han sido anonimizados de forma irreversible.',
|
||||
'gdpr_delete_customer_button' => 'Eliminar cliente (RGPD)',
|
||||
'gdpr_confirm_checkbox_missing' => 'Por favor confirma la eliminación RGPD mediante la casilla de verificación.',
|
||||
'gdpr_error_is_admin' => 'Eliminación RGPD no posible: La cuenta tiene derechos de administrador. Por favor retira primero los derechos de administrador.',
|
||||
'gdpr_error_active_abo' => 'Eliminación RGPD no posible: Todavía existe una suscripción activa. Por favor cancela primero la suscripción.',
|
||||
'gdpr_error_customer_is_member' => 'Eliminación RGPD no posible: Este registro de cliente pertenece a una cuenta de asesor. Por favor utiliza la eliminación RGPD en la administración de usuarios.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ return [
|
|||
'gross' => 'bruto',
|
||||
'gross_price' => 'precio bruto',
|
||||
'incentives' => 'incentivos',
|
||||
'incl_VAT' => 'IVA incl.',
|
||||
'included_VAT' => 'VAT incluido',
|
||||
'invoice' => 'La factura',
|
||||
'invoice_address' => 'dirección de Envio',
|
||||
|
|
|
|||
|
|
@ -68,6 +68,10 @@ return [
|
|||
'in_progress_yellow' => 'En Progreso (amarillo)',
|
||||
'no_level_red' => 'Sin Nivel (rojo)',
|
||||
|
||||
// Distintivos de suscripción (vista de estructura)
|
||||
'badge_own_abo' => 'tiene una suscripción propia',
|
||||
'badge_customer_abos' => '{1} 1 suscripción propia de cliente|[2,*] :count suscripciones propias de clientes',
|
||||
|
||||
// Performance & Badges
|
||||
'optimized' => 'OPTIMIZADO',
|
||||
'standard_monitoring' => 'ESTÁNDAR + MONITOREO',
|
||||
|
|
|
|||
|
|
@ -43,6 +43,24 @@ return [
|
|||
'error_change_locked' => 'Les modifications ne sont plus possibles. La prochaine exécution est dans :days jours. Les modifications doivent être effectuées au moins :lock jours avant.',
|
||||
'error_abo_interval_too_soon' => 'Le jour de livraison choisi n’est qu’à :days jours. Veuillez choisir un jour de livraison situé au moins :lock jours dans le futur.',
|
||||
'error_cancel_locked' => 'Une résiliation n’est plus possible. La prochaine exécution est dans :days jours. Les résiliations doivent être effectuées au moins :lock jours avant.',
|
||||
'abo_copy_pause' => 'Reporte la prochaine exécution d’un mois. Possible au maximum 1 fois tous les :months mois.',
|
||||
'payline_abo_turnover' => 'Chiffre d’affaires des abonnements dans la payline',
|
||||
'payline_abo_turnover_hint' => 'Objectif = points d’abonnement par cycle de tous les abonnements actifs de ta payline (abonnements conseillers et clients). Réel = points d’abonnement de la payline déjà facturés au cours du mois.',
|
||||
'payline_target_label' => 'Objectif (points par cycle)',
|
||||
'payline_actual_label' => 'Réel (facturé ce mois-ci)',
|
||||
'payline_hint' => 'Payline = :lines niveaux · :members conseillers',
|
||||
'payline_abo_counts' => ':team_abos abonnements conseillers + :customer_abos abonnements clients dans la payline',
|
||||
'payline_none' => 'Pas encore de payline (aucun niveau de commission atteint).',
|
||||
'abo_points_per_cycle' => 'Points par livraison (portefeuille actuel)',
|
||||
'abo_points_billed' => 'facturé (:month)',
|
||||
'abo_points_expected' => 'prévu jusqu’au :day',
|
||||
'abo_points_expected_hint' => 'Points d’abonnement déjà facturés plus les points de tous les abonnements de ce niveau dont la livraison est encore à venir d’ici le :day du mois.',
|
||||
'pause_abo' => 'Suspendre 1 mois',
|
||||
'confirm_pause' => 'Veux-tu vraiment suspendre l’abonnement pendant un mois ? La prochaine livraison sera reportée au mois suivant.',
|
||||
'pause_success' => 'Ton abonnement a été suspendu pour un mois. La prochaine exécution aura lieu le :date.',
|
||||
'error_pause_interval' => 'L’abonnement ne peut être suspendu qu’une fois tous les :months mois. La prochaine pause est possible à partir du :date.',
|
||||
'error_pause_not_active' => 'Seuls les abonnements actifs peuvent être suspendus.',
|
||||
'info_pause_available_from' => 'Une pause est possible au maximum 1 fois tous les :months mois — de nouveau à partir du :date.',
|
||||
'error_pause_locked' => 'L’abonnement ne peut plus être mis en pause. La prochaine exécution est dans :days jours. La pause doit être effectuée au moins :lock jours avant.',
|
||||
'error_next_date' => 'La date de la prochaine exécution est incorrecte',
|
||||
'checkout_mail_abo_hl' => 'Votre abonnement / livraison régulière.',
|
||||
|
|
|
|||
|
|
@ -27,6 +27,12 @@ return [
|
|||
'desc_comp_added' => 'Ajouté par le système',
|
||||
'desc_comp_removed' => 'Supprimé par le système',
|
||||
'desc_rollback' => 'Réinitialisé à l’origine',
|
||||
'status_history_title' => 'Changements de statut (pause et résiliation)',
|
||||
'status_paused' => 'Suspendu',
|
||||
'status_cancelled' => 'Résilié',
|
||||
'status_deactivated' => 'Désactivé',
|
||||
'status_reactivated' => 'Réactivé',
|
||||
'status_shift' => 'Exécution reportée du :from au :to',
|
||||
'channel_admin' => 'Admin',
|
||||
'channel_user_me' => 'Conseiller',
|
||||
'channel_user_ot' => 'Conseiller (client)',
|
||||
|
|
|
|||
|
|
@ -48,6 +48,12 @@ return [
|
|||
'until' => 'jusqu’au',
|
||||
'welcome_back' => 'Bon retour',
|
||||
'your_shop' => 'Votre boutique',
|
||||
'commission_today' => 'Commission (à ce jour)',
|
||||
'payline_forecast_title' => 'Prévision payline pour le 5 / 10 / 20 / 25',
|
||||
'payline_forecast_hint' => 'Base : chiffre d’affaires clients actuel + chiffre d’affaires équipe (payline). S’y ajoutent, de manière cumulée, les points d’abonnement de ta payline encore à venir jusqu’à la date concernée (y compris tes propres abonnements et abonnements clients). Prévision : pauses, résiliations ou paiements échoués peuvent modifier la valeur.',
|
||||
'forecast_day' => 'Prévision pour le :date',
|
||||
'forecast_upcoming' => 'points d’abonnement à venir',
|
||||
'forecast_done' => 'Date déjà incluse dans le chiffre réel',
|
||||
'monthly_statistics' => 'Statistique mensuelle',
|
||||
'customer_turnover_points' => 'Points chiffre d’affaires clients',
|
||||
'team_turnover_points' => 'Points chiffre d’affaires équipe',
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ return [
|
|||
'homeparty_delete' => 'Auszeitparty supprimée',
|
||||
'VATID_could_not_be_validated' => 'Le numéro de TVA n’a pas pu être validé, veuillez vérifier la saisie',
|
||||
'VATID_successfully_entered' => 'Numéro de TVA saisi avec succès',
|
||||
'VATID_german_not_allowed' => 'Les numéros de TVA allemands ne peuvent pas être utilisés ici. L’autoliquidation ne s’applique qu’aux numéros de TVA étrangers de l’UE.',
|
||||
'VATID_no_longer_valid_taxes_applied' => 'Ton numéro de TVA n’est plus valide. La TVA sera donc appliquée à cette commande.',
|
||||
'reverse_charge_procedure_and_VATID_deleted' => 'Procédure d’autoliquidation et numéro de TVA supprimés',
|
||||
'no_id_card_deposited_please_upload_first' => 'Aucune pièce d’identité déposée, veuillez d’abord la téléverser',
|
||||
'no_trade_licence_deposited_please_upload_first' => 'Aucun extrait d’activité déposé, veuillez d’abord le téléverser',
|
||||
|
|
@ -38,4 +40,11 @@ return [
|
|||
'account_released' => 'Compte activé',
|
||||
'cart_product_not_allowed_for_order_type' => 'Le panier contient des articles qui ne sont pas prévus pour ce type de commande. Veuillez vider le panier et sélectionner uniquement les produits adaptés à cette commande.',
|
||||
'cart_max_weight_reached' => "Le poids d'expédition maximal est atteint. Aucun autre produit ne peut être ajouté.",
|
||||
'gdpr_delete_success' => 'Suppression RGPD exécutée : Toutes les données personnelles ont été anonymisées de manière irréversible.',
|
||||
'gdpr_delete_customer_success' => 'Suppression RGPD exécutée : Toutes les données personnelles du client ont été anonymisées de manière irréversible.',
|
||||
'gdpr_delete_customer_button' => 'Supprimer le client (RGPD)',
|
||||
'gdpr_confirm_checkbox_missing' => 'Veuillez confirmer la suppression RGPD via la case à cocher.',
|
||||
'gdpr_error_is_admin' => "Suppression RGPD impossible : Le compte dispose de droits d'administrateur. Veuillez d'abord retirer les droits d'administrateur.",
|
||||
'gdpr_error_active_abo' => "Suppression RGPD impossible : Un abonnement actif existe encore. Veuillez d'abord résilier l'abonnement.",
|
||||
'gdpr_error_customer_is_member' => "Suppression RGPD impossible : Cette fiche client appartient à un compte de conseiller. Veuillez utiliser la suppression RGPD dans l'administration des utilisateurs.",
|
||||
];
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ return [
|
|||
'gross' => 'brut',
|
||||
'gross_price' => 'Prix brut',
|
||||
'incentives' => 'Incentives',
|
||||
'incl_VAT' => 'TVA incl.',
|
||||
'included_VAT' => 'TVA incluse',
|
||||
'invoice' => 'Facture',
|
||||
'invoice_address' => 'Adresse de facturation',
|
||||
|
|
|
|||
|
|
@ -67,6 +67,8 @@ return [
|
|||
'qualified_green' => 'Qualifié (vert)',
|
||||
'in_progress_yellow' => 'En cours (jaune)',
|
||||
'no_level_red' => 'Aucun niveau (rouge)',
|
||||
'badge_own_abo' => 'a son propre abonnement',
|
||||
'badge_customer_abos' => '{1} 1 abonnement client propre|[2,*] :count abonnements clients propres',
|
||||
'optimized' => 'OPTIMISÉ',
|
||||
'standard_monitoring' => 'STANDARD + MONITORING',
|
||||
'fallback' => 'FALLBACK',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
@php
|
||||
$changeHistory = $user_abo->getChangeHistory();
|
||||
$statusHistory = $user_abo->status_histories()->with('actor.account')->orderByDesc('created_at')->orderByDesc('id')->get();
|
||||
$priceLabel = $user_abo->is_for === 'me' ? __('abo_history.price_net') : __('abo_history.price_gross');
|
||||
@endphp
|
||||
|
||||
|
|
@ -20,6 +21,34 @@
|
|||
@endif
|
||||
</div>
|
||||
|
||||
@if($statusHistory->isNotEmpty())
|
||||
<h6 class="font-weight-semibold text-muted mb-2">{{ __('abo_history.status_history_title') }}</h6>
|
||||
<div class="table-responsive mb-4">
|
||||
<table class="table table-sm table-striped mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ __('abo_history.col_date') }}</th>
|
||||
<th>{{ __('abo_history.col_action') }}</th>
|
||||
<th>{{ __('abo_history.col_details') }}</th>
|
||||
<th>{{ __('abo_history.col_changed_by') }}</th>
|
||||
<th>{{ __('abo_history.col_channel') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($statusHistory as $statusEntry)
|
||||
<tr>
|
||||
<td class="text-nowrap small">{{ $statusEntry->getFormattedDate() }}</td>
|
||||
<td>{!! $statusEntry->getActionBadge() !!}</td>
|
||||
<td class="small">{{ $statusEntry->getDescription() }}</td>
|
||||
<td class="small">{{ $statusEntry->getActorName() }}</td>
|
||||
<td>{!! $statusEntry->getChannelBadge() !!}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($changeHistory->isEmpty())
|
||||
<p class="text-muted mb-0">
|
||||
<i class="fas fa-info-circle"></i> {{ __('abo_history.no_changes') }}
|
||||
|
|
|
|||
|
|
@ -112,11 +112,11 @@
|
|||
|
||||
<div class="col-md-3 mb-3">
|
||||
{!! $user_abo->getIsForFormated() !!}
|
||||
@if ($user_abo->is_for === 'me')
|
||||
@if ($user_abo->is_for === 'me' && Route::has('user_edit'))
|
||||
<a class="btn btn-xs btn-secondary" href="{{ route('user_edit') }}"><i class="fa fa-edit"></i></a>
|
||||
@endif
|
||||
|
||||
@if ($user_abo->is_for === 'ot')
|
||||
@if ($user_abo->is_for === 'ot' && $user_abo->shopping_user && Route::has('user_customer_edit'))
|
||||
<a class="btn btn-xs btn-secondary"
|
||||
href="{{ route('user_customer_edit', $user_abo->shopping_user->id) }}"><i
|
||||
class="fa fa-edit"></i></a>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
@php($cartInstance = \App\Services\AboOrderCart::INSTANCE)
|
||||
@php($change_locked = isset($change_locked) ? $change_locked : false)
|
||||
{{-- Kunden-Portal: Versand brutto ("inkl. MwSt."), Berater/Admin: netto ("netto") --}}
|
||||
@php($portal_view = $portal_view ?? false)
|
||||
@if(isset($error_message) && $error_message)
|
||||
<div class="alert alert-danger mt-2" id="insert_show_error_message">{{ $error_message }}</div>
|
||||
@endif
|
||||
|
|
@ -88,9 +90,10 @@
|
|||
<td colspan="3" class="text-right small no-border-top"><strong>{{ __('Delivery country') }}:</strong></td>
|
||||
<td class="text-right small no-border-top">{{ Yard::instance($cartInstance)->getShippingCountryName() }}</td>
|
||||
</tr>
|
||||
@php($shippingGross = $portal_view && ! $taxFree)
|
||||
<tr>
|
||||
<td colspan="3" class="text-right small no-border-top"><strong>{{ __('order.shipping_costs') }}:</strong></td>
|
||||
<td class="text-right small no-border-top">{{ $taxFree ? Yard::instance($cartInstance)->shippingNet() : Yard::instance($cartInstance)->shipping() }} € </td>
|
||||
<td colspan="3" class="text-right small no-border-top"><strong>{{ __('order.shipping_costs') }} ({{ $shippingGross ? __('order.incl_VAT') : __('order.net') }}):</strong></td>
|
||||
<td class="text-right small no-border-top">{{ $shippingGross ? Yard::instance($cartInstance)->shipping() : Yard::instance($cartInstance)->shippingNet() }} € </td>
|
||||
</tr>
|
||||
@if($taxFree)
|
||||
<tr>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
@php
|
||||
$cartInstance = \App\Services\AboOrderCart::INSTANCE;
|
||||
$taxFree = Yard::instance($cartInstance)->getUserTaxFree();
|
||||
// Kunden-Portal: Versand brutto ("inkl. MwSt."), Berater/Admin: netto ("netto")
|
||||
$portal_view = $portal_view ?? false;
|
||||
$shippingGross = $portal_view && ! $taxFree;
|
||||
@endphp
|
||||
<div class="card-header bg-primary">
|
||||
<h5 class="font-weight-semibold mb-0 text-white"> <i class="fa fa-shopping-cart text-white"></i>
|
||||
|
|
@ -26,9 +29,10 @@
|
|||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="small no-border-top"><strong>{{ __('order.shipping_costs') }}:</strong></td>
|
||||
<td class="small no-border-top"><strong>{{ __('order.shipping_costs') }}
|
||||
({{ $shippingGross ? __('order.incl_VAT') : __('order.net') }}):</strong></td>
|
||||
<td class="text-right small no-border-top">
|
||||
{{ $taxFree ? Yard::instance($cartInstance)->shippingNet() : Yard::instance($cartInstance)->shipping() }}
|
||||
{{ $shippingGross ? Yard::instance($cartInstance)->shipping() : Yard::instance($cartInstance)->shippingNet() }}
|
||||
€</td>
|
||||
</tr>
|
||||
@if ($taxFree)
|
||||
|
|
|
|||
|
|
@ -90,8 +90,7 @@
|
|||
$canCancel = App\Services\AboHelper::canCancelAbo($user_abo, $data['view']);
|
||||
@endphp
|
||||
<hr>
|
||||
@if($canCancel || $data['view'] == 'admin')
|
||||
|
||||
@if($data['view'] === 'admin')
|
||||
<div class="form-row">
|
||||
<div class="col-sm-12">
|
||||
<label class="switcher switcher-primary">
|
||||
|
|
@ -106,23 +105,49 @@
|
|||
<div class="col-sm-12 mt-1">
|
||||
<i class="text-muted">{{ __('abo.abo_copy_active') }}</i>
|
||||
</div>
|
||||
|
||||
@if($data['view'] !== 'admin')
|
||||
<div class="col-sm-12 mt-3">
|
||||
<button type="submit" name="abo_cancel" value="true" class="btn btn-danger btn-sm" onclick="return confirm('{{ __('abo.confirm_cancel') }}')">
|
||||
<i class="ion ion-ios-trash"></i> {{ __('abo.cancel_abo') }}
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
<input type="hidden" name="abo_is_active" value="{{ $user_abo->active }}">
|
||||
<div class="mt-1">
|
||||
<div class="alert alert-warning">
|
||||
{{ __('abo.info_min_duration_orders_left', ['count' => App\Services\AboHelper::getAboMinDuration() - $user_abo->getCountPaidOrders()]) }}
|
||||
@elseif($canCancel)
|
||||
{{-- Selfservice: kein Aktiv-Schalter mehr (die Pause übernimmt diese Rolle),
|
||||
aktueller Zustand wird unverändert mitgesendet. --}}
|
||||
<input type="hidden" name="abo_is_active" value="{{ $user_abo->active }}">
|
||||
|
||||
@php
|
||||
$canPause = App\Services\AboHelper::canPauseAbo($user_abo, $data['view']);
|
||||
$nextPossiblePause = App\Services\AboHelper::nextPossiblePauseDate($user_abo);
|
||||
@endphp
|
||||
@if($canPause || ($user_abo->active && $nextPossiblePause))
|
||||
<div class="form-row">
|
||||
<div class="col-sm-12">
|
||||
@if($canPause)
|
||||
<button type="submit" name="abo_pause" value="true" class="btn btn-outline-primary btn-sm" onclick="return confirm('{{ __('abo.confirm_pause') }}')">
|
||||
<i class="ion ion-md-pause"></i> {{ __('abo.pause_abo') }}
|
||||
</button>
|
||||
<div class="mt-1">
|
||||
<i class="text-muted">{{ __('abo.abo_copy_pause', ['months' => App\Services\AboHelper::getPauseIntervalMonths()]) }}</i>
|
||||
</div>
|
||||
@else
|
||||
<i class="text-muted">{{ __('abo.info_pause_available_from', ['date' => $nextPossiblePause->format(Util::formatDateDB()), 'months' => App\Services\AboHelper::getPauseIntervalMonths()]) }}</i>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
@endif
|
||||
|
||||
<div class="form-row">
|
||||
<div class="col-sm-12">
|
||||
<button type="submit" name="abo_cancel" value="true" class="btn btn-outline-danger btn-sm" onclick="return confirm('{{ __('abo.confirm_cancel') }}')">
|
||||
<i class="ion ion-ios-trash"></i> {{ __('abo.cancel_abo') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<input type="hidden" name="abo_is_active" value="{{ $user_abo->active }}">
|
||||
<div class="mt-1">
|
||||
<div class="alert alert-warning">
|
||||
{{ __('abo.info_min_duration_orders_left', ['count' => App\Services\AboHelper::getAboMinDuration() - $user_abo->getCountPaidOrders()]) }}
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -9,4 +9,40 @@
|
|||
@include('admin.customer._detail')
|
||||
<a href="{{route('admin_customers')}}" class="btn btn-sm btn-default mt-2 float-right">{{ __('back') }}</a>
|
||||
|
||||
@if (Auth::user()->isSuperAdmin() && !$shopping_user->auth_user_id)
|
||||
<button type="button" class="btn btn-sm btn-danger mt-2" data-toggle="modal"
|
||||
data-target="#modals-customer-gdpr-delete">{{ __('msg.gdpr_delete_customer_button') }}</button>
|
||||
|
||||
<!-- Modal DSGVO-Löschung -->
|
||||
<div class="modal fade" id="modals-customer-gdpr-delete">
|
||||
<div class="modal-dialog">
|
||||
<form class="modal-content" action="{{ route('admin_customer_gdpr_delete', [$shopping_user->id]) }}"
|
||||
method="post">
|
||||
@csrf
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{{ __('msg.gdpr_delete_customer_button') }}</h5>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Alle personenbezogenen Daten dieses Kunden werden <b>unwiderruflich anonymisiert</b>
|
||||
(alle Einträge mit der Kundennummer {{ $shopping_user->number }}).
|
||||
Bei Bestellungen mit Rechnung bleiben Name und Rechnungsadresse wegen der
|
||||
gesetzlichen Aufbewahrungspflichten erhalten; alle Kontaktdaten
|
||||
(E-Mail, Telefon, Notizen, Newsletter) werden entfernt.
|
||||
Aktive Abos müssen vorher gekündigt werden.</p>
|
||||
<label class="custom-control custom-checkbox m-0">
|
||||
<input type="checkbox" class="custom-control-input" name="gdpr_delete_customer" value="1">
|
||||
<span class="custom-control-label">DSGVO-Löschung unwiderruflich ausführen?</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">{{ __('close') }}</button>
|
||||
<button type="submit"
|
||||
class="btn btn-danger">{{ __('msg.gdpr_delete_customer_button') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@endsection
|
||||
|
|
@ -50,15 +50,15 @@
|
|||
--}}
|
||||
<tr class="unselectable">
|
||||
<td>Bankinhaber</td>
|
||||
<td><div class="selectall">{{ $UserCredit->user->account->bank_owner }}</div></td>
|
||||
<td><div class="selectall">{{ $UserCredit->user?->account?->bank_owner }}</div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>IBAN:</td>
|
||||
<td><div class="selectall">{{ $UserCredit->user->account->bank_iban }}</div></td>
|
||||
<td><div class="selectall">{{ $UserCredit->user?->account?->bank_iban }}</div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>BIC:</td>
|
||||
<td><div class="selectall">{{ $UserCredit->user->account->bank_bic }}</div></td>
|
||||
<td><div class="selectall">{{ $UserCredit->user?->account?->bank_bic }}</div></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
|
|||
|
|
@ -124,6 +124,14 @@
|
|||
<small class="form-text text-muted">Anzahl Tage vor der Ausführung, ab der das Abo nicht mehr pausiert oder gekündigt werden kann (Standard: 3).</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group col-sm-6">
|
||||
<label class="form-label">Pause-Intervall (Monate)*</label>
|
||||
{{ Form::text('settings[abo-pause-interval-months][val]', \App\Models\Setting::getContentBySlug('abo-pause-interval-months'), array('class'=>'form-control')) }}
|
||||
{{ Form::hidden('settings[abo-pause-interval-months][type]', 'int') }}
|
||||
<small class="form-text text-muted">Maximal eine Pause pro X Monate (rollierend ab der letzten Pause, Standard: 6).</small>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" name="action" value="save_prepayment" class="btn btn-primary btn-sm mb-2"><i class="ion ion-ios-save"></i> speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -359,6 +359,19 @@
|
|||
<span class="custom-control-label">User komplett ohne Wiederherstellung löschen?</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<hr>
|
||||
<h5 class="text-danger">DSGVO-Löschung (Art. 17 DSGVO)</h5>
|
||||
<p>Alle personenbezogenen Daten dieses Users werden <b>unwiderruflich anonymisiert</b>
|
||||
(Stammdaten, Adressen, Bankdaten, Uploads, Zahlungs-Tokens, Kontaktdaten).
|
||||
Die Downline wird auf den nächsten aktiven Sponsor übertragen.
|
||||
Rechnungen, Gutschriften und Provisionsdaten bleiben wegen der gesetzlichen
|
||||
Aufbewahrungspflichten erhalten. Aktive Abos müssen vorher gekündigt werden.</p>
|
||||
<label class="custom-control custom-checkbox m-0">
|
||||
<input type="checkbox" class="custom-control-input" name="gdpr_delete_user">
|
||||
<span class="custom-control-label">DSGVO-Löschung unwiderruflich ausführen?</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">{{ __('close') }}</button>
|
||||
|
|
|
|||
|
|
@ -13,12 +13,17 @@
|
|||
? route('user_backoffice_statistics_details', ['line' => 0, 'metric' => 'new_partners', 'month' => $m, 'year' => $y])
|
||||
: null;
|
||||
|
||||
// "+N"-Badge der Team-Abo-Kacheln: VIP-Drilldown gefiltert auf neue Abos im Monat.
|
||||
$newAbosLink = fn (string $metric) => $isVip
|
||||
? route('user_backoffice_statistics_details', ['line' => 0, 'metric' => $metric, 'month' => $m, 'year' => $y, 'only_new' => 1])
|
||||
: null;
|
||||
|
||||
$fmt = fn ($value) => number_format((float) $value, 0, ',', '.');
|
||||
|
||||
$clusters = [
|
||||
[
|
||||
'title' => __('home.stats_group_turnover'),
|
||||
'col' => 'col-md-6 col-lg-4',
|
||||
'col' => 'col-md-6 col-lg-3',
|
||||
'tiles' => [
|
||||
[
|
||||
'label' => __('home.customer_turnover_points'), 'value' => $fmt($s['customer_points']),
|
||||
|
|
@ -32,6 +37,12 @@
|
|||
'label' => __('abo.shop_1000'), 'value' => $fmt($s['shop_1000']),
|
||||
'icon' => 'ion-md-trophy', 'color' => 'success', 'link' => $shop1000Link, 'new' => null, 'live' => false,
|
||||
],
|
||||
[
|
||||
'label' => __('home.commission_today'),
|
||||
'value' => number_format((float) ($s['commission']['total'] ?? 0), 2, ',', '.') . ' €',
|
||||
'icon' => 'ion-md-cash', 'color' => 'warning', 'link' => null, 'new' => null,
|
||||
'live' => (bool) ($s['commission']['is_live'] ?? false),
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
|
|
@ -62,11 +73,11 @@
|
|||
],
|
||||
[
|
||||
'label' => __('home.team_subscriptions'), 'value' => $fmt($s['team_partner_abos']),
|
||||
'icon' => 'ion-md-sync', 'color' => 'dark', 'link' => $teamShow, 'new' => $s['team_partner_abos_new'], 'live' => false,
|
||||
'icon' => 'ion-md-sync', 'color' => 'dark', 'link' => $teamShow, 'new' => $s['team_partner_abos_new'], 'new_link' => $newAbosLink('team_partner_abos'), 'live' => false,
|
||||
],
|
||||
[
|
||||
'label' => __('home.team_customer_subscriptions'), 'value' => $fmt($s['team_customer_abos']),
|
||||
'icon' => 'ion-md-git-network', 'color' => 'dark', 'link' => $teamShow, 'new' => $s['team_customer_abos_new'], 'live' => false,
|
||||
'icon' => 'ion-md-git-network', 'color' => 'dark', 'link' => $teamShow, 'new' => $s['team_customer_abos_new'], 'new_link' => $newAbosLink('team_customer_abos'), 'live' => false,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
|
@ -129,8 +140,14 @@
|
|||
<div class="font-weight-bold text-large">
|
||||
{{ $tile['value'] }}
|
||||
@if (! empty($tile['new']) && $tile['new'] > 0)
|
||||
<span class="badge badge-pill badge-success ml-1"
|
||||
title="{{ __('abo.new_this_month') }}">+{{ $tile['new'] }}</span>
|
||||
@if (! empty($tile['new_link']))
|
||||
<span class="badge badge-pill badge-success ml-1 dashboard-stat-new-badge"
|
||||
data-href="{{ $tile['new_link'] }}" role="link" tabindex="0"
|
||||
title="{{ __('abo.new_this_month') }}">+{{ $tile['new'] }}</span>
|
||||
@else
|
||||
<span class="badge badge-pill badge-success ml-1"
|
||||
title="{{ __('abo.new_this_month') }}">+{{ $tile['new'] }}</span>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -143,6 +160,37 @@
|
|||
@endforeach
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
@if (! empty($s['payline_forecast']['days']))
|
||||
<h6 class="text-muted text-uppercase font-weight-bold small mb-3 mt-2 pt-3 border-top">
|
||||
{{ __('home.payline_forecast_title') }}
|
||||
<span class="badge badge-pill badge-info ml-1"
|
||||
title="{{ __('home.payline_forecast_hint') }}">Live</span>
|
||||
</h6>
|
||||
<div class="row">
|
||||
@foreach ($s['payline_forecast']['days'] as $forecastDay)
|
||||
<div class="col-sm-6 col-lg-3 mb-3">
|
||||
<div class="card h-100 {{ $forecastDay['is_past'] ? 'bg-light' : '' }}">
|
||||
<div class="card-body py-3 text-center">
|
||||
<div class="text-muted small">
|
||||
{{ __('home.forecast_day', ['date' => $forecastDay['date']]) }}
|
||||
</div>
|
||||
<div class="font-weight-bold text-large">{{ $fmt($forecastDay['total']) }}</div>
|
||||
@if ($forecastDay['upcoming'] > 0)
|
||||
<div class="small text-success">
|
||||
+{{ $fmt($forecastDay['upcoming']) }} {{ __('home.forecast_upcoming') }}
|
||||
</div>
|
||||
@else
|
||||
<div class="small text-muted">
|
||||
{{ $forecastDay['is_past'] ? __('home.forecast_done') : '—' }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -175,6 +223,14 @@
|
|||
$('#stats-month-filter, #stats-year-filter').on('change', function() {
|
||||
updateStatsFilter();
|
||||
});
|
||||
|
||||
// Das "+N"-Badge liegt innerhalb des Kachel-Links: eigenen Ziel-Link
|
||||
// öffnen statt der Kachel-Navigation (kein verschachteltes <a> möglich).
|
||||
$('.dashboard-stat-new-badge').css('cursor', 'pointer').on('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.location.href = $(this).data('href');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@endif
|
||||
|
|
|
|||
|
|
@ -152,10 +152,13 @@
|
|||
{{ Yard::instance('subscription')->getShippingCountryName() }}
|
||||
</td>
|
||||
</tr>
|
||||
@php($checkTaxFree = Yard::instance('subscription')->getUserTaxFree())
|
||||
<tr>
|
||||
<td>{{ __('order.shipping_costs') }}:</td>
|
||||
<td>{{ __('order.shipping_costs') }}
|
||||
({{ $checkTaxFree ? __('order.net') : __('order.incl_VAT') }}):</td>
|
||||
<td class="text-right">
|
||||
{{ Yard::instance('subscription')->shipping() }} €
|
||||
{{ $checkTaxFree ? Yard::instance('subscription')->shippingNet() : Yard::instance('subscription')->shipping() }}
|
||||
€
|
||||
</td>
|
||||
</tr>
|
||||
@if (Yard::instance('subscription')->getUserTaxFree())
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@
|
|||
'split_mode' => true,
|
||||
'summary' => $summary,
|
||||
'change_locked' => $aboChangeLocked,
|
||||
'portal_view' => true,
|
||||
])
|
||||
</div>
|
||||
<div id="holder_html_view_comp_product">
|
||||
|
|
@ -106,7 +107,7 @@
|
|||
</div>
|
||||
<div class="col-lg-4" id="combined_summary_col">
|
||||
<div class="card js-abo-sticky" id="combined_summary_card">
|
||||
@include('admin.abo._order_combined_summary', ['summary' => $summary])
|
||||
@include('admin.abo._order_combined_summary', ['summary' => $summary, 'portal_view' => true])
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -115,6 +116,7 @@
|
|||
@include('admin.abo._order_abo', [
|
||||
'add_only_mode' => $addOnlyMode,
|
||||
'change_locked' => $aboChangeLocked,
|
||||
'portal_view' => true,
|
||||
])
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -23,8 +23,6 @@
|
|||
class="ion ion-ios-arrow-forward"></i> Kundenhoheit prüfen</a>
|
||||
<a href="{{ route('sysadmin_tool', ['cronjobs']) }}" class="d-block mb-3"><i
|
||||
class="ion ion-ios-arrow-forward"></i> Cron Jobs</a>
|
||||
<a href="{{ route('sysadmin_tool', ['domainssl']) }}" class="d-block mb-3"><i
|
||||
class="ion ion-ios-arrow-forward"></i> Subdomains prüfen SSL/Aktiv</a>
|
||||
<a href="{{ route('sysadmin_tool', ['shopping_orders']) }}" class="d-block mb-3"><i
|
||||
class="ion ion-ios-arrow-forward"></i> Shopping Orders Käufe</a>
|
||||
<a href="{{ route('sysadmin_tool', ['import']) }}" class="d-block mb-3"><i
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue