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:
Kevin 2026-07-24 13:51:16 +00:00
parent 657d4bd5b7
commit ac3bd6e8ea
136 changed files with 7610 additions and 2688 deletions

View file

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

View file

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

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

View file

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