From ef7e333bd45af8b6289b0622be5379d6367e019a Mon Sep 17 00:00:00 2001 From: Kevin Date: Wed, 23 Sep 2026 15:43:58 +0000 Subject: [PATCH] Berater uebertragen: kompletten Berater- und Kundenstamm an einen anderen Berater Ticket dev/_tickets/2026-09-08.md #1: neuer Punkt "Berater uebertragen" im Superadmin (/admin/user/transfer). Vorschau zeigt Firstline, Downline, geparkte Sponsor-Bezuege, Kunden, Kundenabos und Portal-Logins; nach Bestaetigung wird alles in einer Transaktion umgehaengt und wie die User-Bereinigung protokolliert (user_clean_up_logs, shopping_user_member_logs, SysLog). Bestellungen, Umsaetze und Provisionsdaten bleiben beim bisherigen Berater. Ziel unterhalb der Firstline der Quelle wird abgelehnt (Kreis im Baum). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ConsultantTransferException.php | 11 + .../AdminUserTransferController.php | 105 +++++++ .../ExecuteConsultantTransferRequest.php | 30 ++ .../PreviewConsultantTransferRequest.php | 41 +++ app/Services/ConsultantTransferService.php | 247 ++++++++++++++++ resources/lang/de/msg.php | 7 + resources/lang/de/navigation.php | 1 + resources/lang/en/msg.php | 7 + resources/lang/en/navigation.php | 1 + resources/lang/es/msg.php | 7 + resources/lang/es/navigation.php | 1 + resources/lang/fr/msg.php | 7 + resources/lang/fr/navigation.php | 1 + .../views/admin/user/transfer/index.blade.php | 146 +++++++++ .../layouts/includes/layout-sidenav.blade.php | 6 + routes/domains/crm.php | 5 + tests/Feature/ConsultantTransferTest.php | 279 ++++++++++++++++++ 17 files changed, 902 insertions(+) create mode 100644 app/Exceptions/ConsultantTransferException.php create mode 100644 app/Http/Controllers/AdminUserTransferController.php create mode 100644 app/Http/Requests/UserTransfer/ExecuteConsultantTransferRequest.php create mode 100644 app/Http/Requests/UserTransfer/PreviewConsultantTransferRequest.php create mode 100644 app/Services/ConsultantTransferService.php create mode 100644 resources/views/admin/user/transfer/index.blade.php create mode 100644 tests/Feature/ConsultantTransferTest.php diff --git a/app/Exceptions/ConsultantTransferException.php b/app/Exceptions/ConsultantTransferException.php new file mode 100644 index 00000000..905a6604 --- /dev/null +++ b/app/Exceptions/ConsultantTransferException.php @@ -0,0 +1,11 @@ +middleware('superadmin'); + } + + public function index(): View + { + return view('admin.user.transfer.index', $this->viewData()); + } + + public function preview(PreviewConsultantTransferRequest $request): View|RedirectResponse + { + $source = User::findOrFail($request->integer('source_user_id')); + $target = User::findOrFail($request->integer('target_user_id')); + + try { + $this->transferService->assertTransferable($source, $target); + } catch (ConsultantTransferException $e) { + return redirect(route('admin_user_transfer'))->withInput()->with('alert-error', $e->getMessage()); + } + + return view('admin.user.transfer.index', $this->viewData($source, $target, $this->transferService->preview($source))); + } + + public function execute(ExecuteConsultantTransferRequest $request): RedirectResponse + { + $source = User::findOrFail($request->integer('source_user_id')); + $target = User::findOrFail($request->integer('target_user_id')); + + try { + $summary = $this->transferService->transfer($source, $target, Auth::id()); + } catch (ConsultantTransferException $e) { + return redirect(route('admin_user_transfer'))->withInput()->with('alert-error', $e->getMessage()); + } + + \Session()->flash('alert-success', __('msg.transfer_success', [ + 'firstline' => $summary['firstline'], + 'customers' => $summary['customers'], + 'abos' => $summary['abos'], + 'target' => $target->email, + ])); + + return redirect(route('admin_user_transfer')); + } + + /** + * @return array + */ + protected function viewData(?User $source = null, ?User $target = null, ?array $preview = null): array + { + return [ + 'source' => $source, + 'target' => $target, + 'preview' => $preview, + 'sourceOptions' => $this->userOptions(User::query()), + 'targetOptions' => $this->userOptions( + User::where('active', true)->where('blocked', false)->where('payment_account', '>=', now()) + ), + ]; + } + + /** + * Optionen für die Berater-Auswahl: "Vorname Nachname | E-Mail #M-Account". + * + * @param Builder $query + * @return array + */ + protected function userOptions($query): array + { + $options = ['' => __('please select')]; + foreach ($query->with('account')->orderBy('email')->get() as $user) { + $options[$user->id] = $this->userLabel($user); + } + + return $options; + } + + public static function userLabel(User $user): string + { + $name = $user->account ? trim($user->account->first_name.' '.$user->account->last_name) : ''; + $mAccount = $user->account && $user->account->m_account ? ' #'.$user->account->m_account : ''; + + return ($name !== '' ? $name.' | ' : '').$user->email.$mAccount; + } +} diff --git a/app/Http/Requests/UserTransfer/ExecuteConsultantTransferRequest.php b/app/Http/Requests/UserTransfer/ExecuteConsultantTransferRequest.php new file mode 100644 index 00000000..fe600a98 --- /dev/null +++ b/app/Http/Requests/UserTransfer/ExecuteConsultantTransferRequest.php @@ -0,0 +1,30 @@ +> + */ + public function rules(): array + { + return array_merge(parent::rules(), [ + 'confirm_transfer' => ['accepted'], + ]); + } + + /** + * @return array + */ + public function messages(): array + { + return array_merge(parent::messages(), [ + 'confirm_transfer.accepted' => __('msg.transfer_confirm_checkbox_missing'), + ]); + } +} diff --git a/app/Http/Requests/UserTransfer/PreviewConsultantTransferRequest.php b/app/Http/Requests/UserTransfer/PreviewConsultantTransferRequest.php new file mode 100644 index 00000000..8dad5ec0 --- /dev/null +++ b/app/Http/Requests/UserTransfer/PreviewConsultantTransferRequest.php @@ -0,0 +1,41 @@ +> + */ + public function rules(): array + { + return [ + 'source_user_id' => ['required', 'integer', 'exists:users,id'], + 'target_user_id' => ['required', 'integer', 'exists:users,id', 'different:source_user_id'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'source_user_id.required' => __('msg.transfer_error_source_required'), + 'target_user_id.required' => __('msg.transfer_error_target_required'), + 'target_user_id.different' => __('msg.transfer_error_same_user'), + ]; + } +} diff --git a/app/Services/ConsultantTransferService.php b/app/Services/ConsultantTransferService.php new file mode 100644 index 00000000..3a8ef60a --- /dev/null +++ b/app/Services/ConsultantTransferService.php @@ -0,0 +1,247 @@ +, + * firstline_count: int, + * downline_count: int, + * pre_sponsor_count: int, + * customers_count: int, + * customers_deleted_count: int, + * abos_count: int, + * abos_active_count: int, + * portal_customers_count: int + * } + */ + public function preview(User $source): array + { + $firstline = $this->firstlineQuery($source)->with('account')->orderBy('email')->get(); + $customers = ShoppingUser::withTrashed()->where('member_id', $source->id); + $abos = UserAbo::withTrashed()->where('member_id', $source->id); + + return [ + 'firstline' => $firstline, + 'firstline_count' => $firstline->count(), + 'downline_count' => count($this->downlineUserIds($source)), + 'pre_sponsor_count' => $this->preSponsorQuery($source)->count(), + 'customers_count' => (clone $customers)->count(), + 'customers_deleted_count' => (clone $customers)->whereNotNull('deleted_at')->count(), + 'abos_count' => (clone $abos)->count(), + 'abos_active_count' => (clone $abos)->where('active', true)->count(), + 'portal_customers_count' => Customer::where('member_id', $source->id)->count(), + ]; + } + + /** + * Prüft, ob eine Übertragung von $source auf $target zulässig ist. + * + * Ein Zielberater unterhalb der Firstline der Quelle würde einen Zyklus + * erzeugen (seine eigene Upline würde unter ihn gehängt) und wird abgelehnt. + * Ein Zielberater direkt in der Firstline ist erlaubt: Er behält seinen + * eigenen Sponsor und bekommt die übrigen Firstline-Berater als Downline. + * + * @throws ConsultantTransferException + */ + public function assertTransferable(User $source, User $target): void + { + if ($source->id === $target->id) { + throw new ConsultantTransferException(__('msg.transfer_error_same_user')); + } + + if ($target->deleted_at || ! $target->active || $target->blocked || ! $target->isActiveAccount()) { + throw new ConsultantTransferException(__('msg.transfer_error_target_inactive')); + } + + $isDirectChild = (int) $target->m_sponsor === (int) $source->id; + if (! $isDirectChild && in_array($target->id, $this->downlineUserIds($source), true)) { + throw new ConsultantTransferException(__('msg.transfer_error_target_in_downline')); + } + } + + /** + * Übertragung ausführen. + * + * @return array Zusammenfassung der bewegten Datensätze + * + * @throws ConsultantTransferException + */ + public function transfer(User $source, User $target, ?int $executedByUserId = null): array + { + $this->assertTransferable($source, $target); + + $summary = DB::transaction(function () use ($source, $target): array { + return [ + 'firstline' => $this->transferFirstline($source, $target), + 'pre_sponsor' => $this->transferPreSponsor($source, $target), + 'customers' => $this->transferCustomers($source, $target), + 'abos' => UserAbo::withTrashed()->where('member_id', $source->id)->update(['member_id' => $target->id]), + 'portal_customers' => Customer::where('member_id', $source->id)->update(['member_id' => $target->id]), + ]; + }); + + $this->logTransfer($source, $target, $executedByUserId, $summary); + + return $summary; + } + + /** + * IDs aller Berater unterhalb von $source (alle Ebenen, inkl. soft-deleted). + * + * @return array + */ + public function downlineUserIds(User $source): array + { + $ids = []; + $visited = [$source->id => true]; + $frontier = [$source->id]; + + while ($frontier !== []) { + $children = User::withTrashed() + ->whereIn('m_sponsor', $frontier) + ->whereColumn('id', '!=', 'm_sponsor') + ->pluck('id'); + + $frontier = []; + foreach ($children as $childId) { + $childId = (int) $childId; + if (isset($visited[$childId])) { + continue; + } + $visited[$childId] = true; + $ids[] = $childId; + $frontier[] = $childId; + } + } + + return $ids; + } + + protected function transferFirstline(User $source, User $target): int + { + $count = 0; + foreach ($this->firstlineQuery($source)->where('id', '!=', $target->id)->get() as $child) { + UserCleanUpLog::create([ + 'inactive_sponsor_id' => $source->id, + 'child_user_id' => $child->id, + 'new_sponsor_id' => $target->id, + ]); + $child->m_sponsor = $target->id; + $child->save(); + $count++; + } + + return $count; + } + + protected function transferPreSponsor(User $source, User $target): int + { + $count = 0; + foreach ($this->preSponsorQuery($source)->where('id', '!=', $target->id)->get() as $parked) { + UserCleanUpLog::create([ + 'inactive_sponsor_id' => $source->id, + 'child_user_id' => $parked->id, + 'new_sponsor_id' => $target->id, + ]); + $parked->pre_sponsor = $target->id; + $parked->save(); + $count++; + } + + return $count; + } + + protected function transferCustomers(User $source, User $target): int + { + $count = 0; + foreach (ShoppingUser::withTrashed()->where('member_id', $source->id)->get() as $customer) { + ShoppingUserMemberLog::create([ + 'pre_member_id' => $source->id, + 'shopping_user_id' => $customer->id, + 'new_member_id' => $target->id, + ]); + $customer->member_id = $target->id; + $customer->save(); + $count++; + } + + return $count; + } + + /** + * @return Builder + */ + protected function firstlineQuery(User $source) + { + return User::withTrashed() + ->where('m_sponsor', $source->id) + ->where('id', '!=', $source->id); + } + + /** + * @return Builder + */ + protected function preSponsorQuery(User $source) + { + return User::withTrashed() + ->where('pre_sponsor', $source->id) + ->where('id', '!=', $source->id); + } + + /** + * @param array $summary + */ + protected function logTransfer(User $source, User $target, ?int $executedByUserId, array $summary): void + { + $message = 'Berater-Stamm übertragen von '.$source->email.' (#'.$source->id.') an '.$target->email.' (#'.$target->id.'). Umfang: '.json_encode($summary); + + SysLog::action(self::SYSLOG_ACTION, 'cleanup', 3) + ->setUserId($executedByUserId) + ->setModel($source->id, User::class) + ->setMessage($message) + ->save(); + + Log::channel('cleanup')->info(self::SYSLOG_ACTION.': '.$message.' | by_admin='.($executedByUserId ?? 'system')); + } +} diff --git a/resources/lang/de/msg.php b/resources/lang/de/msg.php index 31f4e916..3f831997 100644 --- a/resources/lang/de/msg.php +++ b/resources/lang/de/msg.php @@ -48,6 +48,13 @@ return [ '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.', + 'transfer_success' => 'Übertragung ausgeführt: :firstline Firstline-Berater, :customers Kunden und :abos Kunden-Abos wurden an :target übertragen.', + 'transfer_error_source_required' => 'Bitte wähle den Berater aus, der seinen Stamm abgibt.', + 'transfer_error_target_required' => 'Bitte wähle den Berater aus, der den Stamm übernimmt.', + 'transfer_error_same_user' => 'Quell- und Zielberater müssen unterschiedlich sein.', + 'transfer_error_target_inactive' => 'Der Zielberater ist nicht aktiv (deaktiviert, gesperrt, gelöscht oder Account abgelaufen).', + 'transfer_error_target_in_downline' => 'Der Zielberater befindet sich unterhalb der Firstline des Quellberaters. Das würde einen Kreis im Baum erzeugen. Bitte einen anderen Zielberater wählen.', + 'transfer_confirm_checkbox_missing' => 'Bitte bestätige die Übertragung über die Checkbox.', 'delete_confirm_checkbox_missing' => 'Bitte bestätige die Löschung über die Checkbox.', 'gdpr_purge_customer_button' => 'Kunde komplett löschen', 'gdpr_purge_customer_success' => 'Kunde wurde komplett gelöscht: :count Kundeneintrag/-einträge entfernt.', diff --git a/resources/lang/de/navigation.php b/resources/lang/de/navigation.php index cd5b449c..f92fc923 100644 --- a/resources/lang/de/navigation.php +++ b/resources/lang/de/navigation.php @@ -57,6 +57,7 @@ return [ 'trigger' => 'auslösen', 'user_roles' => 'User Rechte', 'user_cleanup' => 'User Bereinigung', + 'user_transfer' => 'Berater übertragen', 'user_restore' => 'User Wiederherstellen', 'tax_advisor' => 'Steuerberater', 'downloadcenter' => 'Downloadcenter', diff --git a/resources/lang/en/msg.php b/resources/lang/en/msg.php index b550267e..3dfddfde 100644 --- a/resources/lang/en/msg.php +++ b/resources/lang/en/msg.php @@ -50,6 +50,13 @@ return [ '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.', + 'transfer_success' => 'Transfer executed: :firstline first-line consultants, :customers customers and :abos customer subscriptions were transferred to :target.', + 'transfer_error_source_required' => 'Please select the consultant who hands over their team.', + 'transfer_error_target_required' => 'Please select the consultant who takes over the team.', + 'transfer_error_same_user' => 'Source and target consultant must be different.', + 'transfer_error_target_inactive' => 'The target consultant is not active (deactivated, blocked, deleted or account expired).', + 'transfer_error_target_in_downline' => 'The target consultant is below the first line of the source consultant. This would create a cycle in the tree. Please choose another target consultant.', + 'transfer_confirm_checkbox_missing' => 'Please confirm the transfer via the checkbox.', 'delete_confirm_checkbox_missing' => 'Please confirm the deletion via the checkbox.', 'gdpr_purge_customer_button' => 'Delete customer completely', 'gdpr_purge_customer_success' => 'Customer deleted completely: :count customer record(s) removed.', diff --git a/resources/lang/en/navigation.php b/resources/lang/en/navigation.php index dd333de7..00395f41 100644 --- a/resources/lang/en/navigation.php +++ b/resources/lang/en/navigation.php @@ -69,6 +69,7 @@ return [ 'trigger' => 'trigger', 'user_roles' => 'user rights', 'user_cleanup' => 'User Cleanup', + 'user_transfer' => 'Transfer consultant', 'user_restore' => 'User Restore', 'dhl_cockpit' => 'DHL Cockpit', 'revenue' => 'Revenue', diff --git a/resources/lang/es/msg.php b/resources/lang/es/msg.php index ffc4aa2d..677188f7 100644 --- a/resources/lang/es/msg.php +++ b/resources/lang/es/msg.php @@ -50,6 +50,13 @@ return [ '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.', + 'transfer_success' => 'Transferencia ejecutada: :firstline asesores de primera línea, :customers clientes y :abos suscripciones de clientes fueron transferidos a :target.', + 'transfer_error_source_required' => 'Selecciona el asesor que cede su equipo.', + 'transfer_error_target_required' => 'Selecciona el asesor que asume el equipo.', + 'transfer_error_same_user' => 'El asesor de origen y el de destino deben ser distintos.', + 'transfer_error_target_inactive' => 'El asesor de destino no está activo (desactivado, bloqueado, eliminado o cuenta caducada).', + 'transfer_error_target_in_downline' => 'El asesor de destino está por debajo de la primera línea del asesor de origen. Esto crearía un ciclo en el árbol. Elige otro asesor de destino.', + 'transfer_confirm_checkbox_missing' => 'Confirma la transferencia mediante la casilla.', 'delete_confirm_checkbox_missing' => 'Confirma la eliminación mediante la casilla.', 'gdpr_purge_customer_button' => 'Eliminar cliente por completo', 'gdpr_purge_customer_success' => 'Cliente eliminado por completo: :count registro(s) de cliente eliminado(s).', diff --git a/resources/lang/es/navigation.php b/resources/lang/es/navigation.php index b9248dee..2101d548 100644 --- a/resources/lang/es/navigation.php +++ b/resources/lang/es/navigation.php @@ -66,6 +66,7 @@ return [ 'trigger' => 'desencadenar', 'user_roles' => 'derechos de los usuarios', 'user_cleanup' => 'Limpieza de usuarios', + 'user_transfer' => 'Transferir asesor', 'user_restore' => 'Restaurar usuario', 'dhl_cockpit' => 'Cabina DHL', 'revenue' => 'Ingresos', diff --git a/resources/lang/fr/msg.php b/resources/lang/fr/msg.php index 2d3cfb5d..1692b6e5 100644 --- a/resources/lang/fr/msg.php +++ b/resources/lang/fr/msg.php @@ -48,6 +48,13 @@ return [ '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.", + 'transfer_success' => 'Transfert effectué : :firstline conseillers de première ligne, :customers clients et :abos abonnements clients ont été transférés à :target.', + 'transfer_error_source_required' => 'Veuillez sélectionner le conseiller qui cède son équipe.', + 'transfer_error_target_required' => 'Veuillez sélectionner le conseiller qui reprend l\'équipe.', + 'transfer_error_same_user' => 'Le conseiller source et le conseiller cible doivent être différents.', + 'transfer_error_target_inactive' => 'Le conseiller cible n\'est pas actif (désactivé, bloqué, supprimé ou compte expiré).', + 'transfer_error_target_in_downline' => 'Le conseiller cible se trouve sous la première ligne du conseiller source. Cela créerait un cycle dans l\'arbre. Veuillez choisir un autre conseiller cible.', + 'transfer_confirm_checkbox_missing' => 'Veuillez confirmer le transfert via la case à cocher.', 'delete_confirm_checkbox_missing' => 'Veuillez confirmer la suppression via la case à cocher.', 'gdpr_purge_customer_button' => 'Supprimer complètement le client', 'gdpr_purge_customer_success' => 'Client supprimé complètement : :count enregistrement(s) client supprimé(s).', diff --git a/resources/lang/fr/navigation.php b/resources/lang/fr/navigation.php index 9a06675e..f46dda71 100644 --- a/resources/lang/fr/navigation.php +++ b/resources/lang/fr/navigation.php @@ -55,6 +55,7 @@ return [ 'trigger' => 'déclencher', 'user_roles' => 'Droits utilisateur', 'user_cleanup' => 'Nettoyage utilisateurs', + 'user_transfer' => 'Transférer conseiller', 'user_restore' => 'Restaurer utilisateur', 'tax_advisor' => 'Conseiller fiscal', 'downloadcenter' => 'Centre de téléchargement', diff --git a/resources/views/admin/user/transfer/index.blade.php b/resources/views/admin/user/transfer/index.blade.php new file mode 100644 index 00000000..5502829d --- /dev/null +++ b/resources/views/admin/user/transfer/index.blade.php @@ -0,0 +1,146 @@ +@extends('layouts.layout-2') + +@section('content') +

+ {{ __('navigation.user_transfer') }} +

+ +
+
+
+ Info: Überträgt den gesamten Berater- und Kundenstamm eines Beraters auf einen anderen Berater, + z. B. wenn ein Berater aufhört. Übertragen werden die Firstline (die Teilbäume darunter folgen automatisch), + alle Kunden, deren Abos und Portal-Logins. Bestehende Bestellungen und Provisionen bleiben als Historie beim + bisherigen Berater. Jede Umhängung wird in der + User-Bereinigung protokolliert. +
+
+
+ + @if ($errors->any()) +
+
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif + +
+
1. Berater auswählen
+
+ {!! Form::open(['route' => 'admin_user_transfer_preview', 'method' => 'post']) !!} +
+
+ + {!! Form::select('source_user_id', $sourceOptions, old('source_user_id', $source->id ?? ''), [ + 'id' => 'source_user_id', + 'class' => 'selectpicker form-control', + 'data-live-search' => 'true', + 'data-style' => 'btn-light', + 'required' => 'required', + ]) !!} +
+
+ + {!! Form::select('target_user_id', $targetOptions, old('target_user_id', $target->id ?? ''), [ + 'id' => 'target_user_id', + 'class' => 'selectpicker form-control', + 'data-live-search' => 'true', + 'data-style' => 'btn-light', + 'required' => 'required', + ]) !!} + Nur aktive, nicht gesperrte Berater mit gültigem Account. +
+
+ + {!! Form::close() !!} +
+
+ + @if ($preview && $source && $target) +
+
2. Vorschau und Bestätigung
+
+
+
+
Von
+ {{ \App\Http\Controllers\AdminUserTransferController::userLabel($source) }} +
+ Status: {{ $source->active ? 'aktiv' : 'deaktiviert' }}, + Account bis {{ $source->payment_account ? \Carbon\Carbon::parse($source->payment_account)->format('d.m.Y') : '-' }} +
+
+
+
An
+ {{ \App\Http\Controllers\AdminUserTransferController::userLabel($target) }} + @if ((int) $target->m_sponsor === (int) $source->id) +
Der Zielberater ist selbst in der Firstline des Quellberaters. Er behält seinen Sponsor und bekommt die übrigen Firstline-Berater als Downline.
+ @endif +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
Firstline-Berater (werden direkt umgehängt){{ $preview['firstline_count'] }}
Gesamte Downline (alle Ebenen, folgt automatisch){{ $preview['downline_count'] }}
Deaktivierte Berater mit geparktem Sponsor-Bezug{{ $preview['pre_sponsor_count'] }}
Kunden-Datensätze (davon gelöscht){{ $preview['customers_count'] }} ({{ $preview['customers_deleted_count'] }})
Kunden-Abos (davon aktiv){{ $preview['abos_count'] }} ({{ $preview['abos_active_count'] }})
Kunden-Portal-Logins{{ $preview['portal_customers_count'] }}
+
+ + @if ($preview['firstline']->isNotEmpty()) +
Firstline-Berater:
+
    + @foreach ($preview['firstline']->take(50) as $child) +
  • + {{ \App\Http\Controllers\AdminUserTransferController::userLabel($child) }} + @if ($child->id === $target->id) Zielberater, bleibt @endif + @if ($child->deleted_at) gelöscht @elseif (! $child->active) deaktiviert @endif +
  • + @endforeach + @if ($preview['firstline']->count() > 50) +
  • … und {{ $preview['firstline']->count() - 50 }} weitere
  • + @endif +
+ @endif + + {!! Form::open(['route' => 'admin_user_transfer_execute', 'method' => 'post']) !!} + {!! Form::hidden('source_user_id', $source->id) !!} + {!! Form::hidden('target_user_id', $target->id) !!} + +
+ + Abbrechen +
+ {!! Form::close() !!} +
+
+ @endif +@endsection diff --git a/resources/views/layouts/includes/layout-sidenav.blade.php b/resources/views/layouts/includes/layout-sidenav.blade.php index e5311713..960e91f1 100755 --- a/resources/views/layouts/includes/layout-sidenav.blade.php +++ b/resources/views/layouts/includes/layout-sidenav.blade.php @@ -645,6 +645,12 @@
{{ __('navigation.user_cleanup') }}
+
  • + +
    {{ __('navigation.user_transfer') }}
    +
    +
  • diff --git a/routes/domains/crm.php b/routes/domains/crm.php index 7e11948d..e9188348 100644 --- a/routes/domains/crm.php +++ b/routes/domains/crm.php @@ -516,6 +516,11 @@ Route::domain(config('app.pre_url_crm').config('app.domain').config('app.tld_car Route::post('/admin/user/cleanup/reassign-sponsor', 'AdminUserCleanupController@reassignSponsor')->name('admin_user_cleanup_reassign_sponsor'); Route::post('/admin/user/cleanup/restore', 'AdminUserCleanupController@restore')->name('admin_user_cleanup_restore'); + // Berater übertragen (kompletter Berater- und Kundenstamm), dev/_tickets/2026-09-08.md #1 + Route::get('/admin/user/transfer', 'AdminUserTransferController@index')->name('admin_user_transfer'); + Route::post('/admin/user/transfer/preview', 'AdminUserTransferController@preview')->name('admin_user_transfer_preview'); + Route::post('/admin/user/transfer/execute', 'AdminUserTransferController@execute')->name('admin_user_transfer_execute'); + Route::get('/admin/shippings', 'ShippingController@index')->name('admin_shippings'); Route::get('/admin/shipping/edit/{shipping_id}', 'ShippingController@edit')->name('admin_shipping_edit'); Route::post('/admin/shipping/store', 'ShippingController@store')->name('admin_shipping_store'); diff --git a/tests/Feature/ConsultantTransferTest.php b/tests/Feature/ConsultantTransferTest.php new file mode 100644 index 00000000..0c56580b --- /dev/null +++ b/tests/Feature/ConsultantTransferTest.php @@ -0,0 +1,279 @@ +previousHttpHost = $_SERVER['HTTP_HOST'] ?? null; + $_SERVER['HTTP_HOST'] = config('domains.domains.crm.host'); + $this->refreshApplication(); + $this->artisan('migrate'); +}); + +afterEach(function () { + // Host wieder zurücksetzen, sonst booten nachfolgende Tests mit den CRM-Routen. + if ($this->previousHttpHost === null) { + unset($_SERVER['HTTP_HOST']); + } else { + $_SERVER['HTTP_HOST'] = $this->previousHttpHost; + } +}); + +function transferConsultant(array $attributes = [], string $firstName = 'Test'): User +{ + $account = UserAccount::create([ + 'first_name' => $firstName, + 'last_name' => 'Berater', + 'm_first_name' => $firstName, + 'm_last_name' => 'Berater', + 'm_account' => 'M'.random_int(1000, 9999), + ]); + + return User::forceCreate(array_merge([ + 'email' => strtolower($firstName).'-'.uniqid('', true).'@example.com', + 'password' => bcrypt('secret'), + 'lang' => 'de', + 'active' => 1, + 'blocked' => 0, + 'wizard' => 100, + 'payment_account' => now()->addYear(), + 'account_id' => $account->id, + ], $attributes))->fresh(); +} + +function transferCustomer(User $member, array $attributes = []): ShoppingUser +{ + $country = Country::first() ?? Country::create([ + 'code' => 'DE', 'phone' => '49', 'en' => 'Germany', 'de' => 'Deutschland', + 'es' => 'Alemania', 'fr' => 'Allemagne', 'it' => 'Germania', 'ru' => 'Германия', + ]); + + return ShoppingUser::create(array_merge([ + 'auth_user_id' => 0, + 'member_id' => $member->id, + 'number' => random_int(1000, 99999), + 'billing_firstname' => 'Kunde', + 'billing_lastname' => 'Test', + 'billing_email' => 'kunde-'.uniqid('', true).'@example.com', + 'billing_country_id' => $country->id, + 'shipping_country_id' => $country->id, + ], $attributes)); +} + +it('hängt die Firstline um, die Teilbäume darunter bleiben erhalten', function () { + $source = transferConsultant([], 'Nadine'); + $target = transferConsultant([], 'Claudia'); + $childA = transferConsultant(['m_sponsor' => $source->id], 'ChildA'); + $childB = transferConsultant(['m_sponsor' => $source->id, 'active' => 0], 'ChildB'); + $grandChild = transferConsultant(['m_sponsor' => $childA->id], 'Grand'); + $unrelated = transferConsultant(['m_sponsor' => $target->id], 'Other'); + + $summary = app(ConsultantTransferService::class)->transfer($source, $target, 1); + + expect($summary['firstline'])->toBe(2); + expect($childA->fresh()->m_sponsor)->toBe($target->id); + expect($childB->fresh()->m_sponsor)->toBe($target->id); + expect($grandChild->fresh()->m_sponsor)->toBe($childA->id); + expect($unrelated->fresh()->m_sponsor)->toBe($target->id); + expect($source->fresh()->m_sponsor)->toBeNull(); + expect(UserCleanUpLog::where('inactive_sponsor_id', $source->id)->where('new_sponsor_id', $target->id)->count())->toBe(2); +}); + +it('überträgt Kunden, Kunden-Abos und Portal-Logins, lässt Bestellungen aber beim alten Berater', function () { + $source = transferConsultant([], 'Nadine'); + $target = transferConsultant([], 'Claudia'); + $customer = transferCustomer($source); + $deletedCustomer = transferCustomer($source); + $deletedCustomer->delete(); + $foreignCustomer = transferCustomer($target); + + $shop = UserShop::create(['user_id' => $source->id, 'name' => 'Shop', 'slug' => 'shop-'.uniqid()]); + $order = ShoppingOrder::create([ + 'shopping_user_id' => $customer->id, + 'member_id' => $source->id, + 'auth_user_id' => 0, + 'user_shop_id' => $shop->id, + 'country_id' => $customer->billing_country_id, + 'total' => 10, + 'txaction' => 'appointed', + 'mode' => 'test', + ]); + $abo = UserAbo::create([ + 'user_id' => null, + 'member_id' => $source->id, + 'shopping_user_id' => $customer->id, + 'is_for' => 'ot', + 'email' => $customer->billing_email, + 'payone_userid' => 1, + 'clearingtype' => 'cc', + 'active' => true, + 'status' => 2, + 'abo_interval' => 1, + 'next_date' => now()->addDays(3), + ]); + $portal = Customer::create([ + 'email' => $customer->billing_email, + 'shopping_user_id' => $customer->id, + 'member_id' => $source->id, + 'number' => $customer->number, + ]); + + $summary = app(ConsultantTransferService::class)->transfer($source, $target, 1); + + expect($summary['customers'])->toBe(2); + expect($summary['abos'])->toBe(1); + expect($summary['portal_customers'])->toBe(1); + expect($customer->fresh()->member_id)->toBe($target->id); + expect(ShoppingUser::withTrashed()->find($deletedCustomer->id)->member_id)->toBe($target->id); + expect($foreignCustomer->fresh()->member_id)->toBe($target->id); + expect($abo->fresh()->member_id)->toBe($target->id); + expect($portal->fresh()->member_id)->toBe($target->id); + expect($order->fresh()->member_id)->toBe($source->id); + expect(ShoppingUserMemberLog::where('pre_member_id', $source->id)->where('new_member_id', $target->id)->count())->toBe(2); +}); + +it('überträgt geparkte Sponsor-Bezüge deaktivierter Berater', function () { + $source = transferConsultant([], 'Nadine'); + $target = transferConsultant([], 'Claudia'); + $parked = transferConsultant(['m_sponsor' => null, 'pre_sponsor' => $source->id, 'active' => 0], 'Parked'); + + $summary = app(ConsultantTransferService::class)->transfer($source, $target, 1); + + expect($summary['pre_sponsor'])->toBe(1); + expect($parked->fresh()->pre_sponsor)->toBe($target->id); +}); + +it('erlaubt einen Zielberater aus der Firstline, der seinen eigenen Sponsor behält', function () { + $source = transferConsultant([], 'Nadine'); + $target = transferConsultant(['m_sponsor' => $source->id], 'Claudia'); + $sibling = transferConsultant(['m_sponsor' => $source->id], 'Sibling'); + + app(ConsultantTransferService::class)->transfer($source, $target, 1); + + expect($target->fresh()->m_sponsor)->toBe($source->id); + expect($sibling->fresh()->m_sponsor)->toBe($target->id); +}); + +it('lehnt einen Zielberater unterhalb der Firstline ab, weil das einen Kreis erzeugen würde', function () { + $source = transferConsultant([], 'Nadine'); + $child = transferConsultant(['m_sponsor' => $source->id], 'Child'); + $target = transferConsultant(['m_sponsor' => $child->id], 'Claudia'); + + expect(fn () => app(ConsultantTransferService::class)->transfer($source, $target, 1)) + ->toThrow(ConsultantTransferException::class, __('msg.transfer_error_target_in_downline')); + expect($child->fresh()->m_sponsor)->toBe($source->id); +}); + +it('lehnt denselben Berater als Ziel ab', function () { + $source = transferConsultant([], 'Nadine'); + + expect(fn () => app(ConsultantTransferService::class)->transfer($source, $source, 1)) + ->toThrow(ConsultantTransferException::class, __('msg.transfer_error_same_user')); +}); + +it('lehnt einen inaktiven Zielberater ab', function (array $attributes) { + $source = transferConsultant([], 'Nadine'); + $target = transferConsultant($attributes, 'Claudia'); + $child = transferConsultant(['m_sponsor' => $source->id], 'Child'); + + try { + app(ConsultantTransferService::class)->transfer($source, $target, 1); + $this->fail('Exception erwartet'); + } catch (ConsultantTransferException $e) { + expect($e->getMessage())->toBe(__('msg.transfer_error_target_inactive')); + } + + expect($child->fresh()->m_sponsor)->toBe($source->id); +})->with([ + 'deaktiviert' => [['active' => 0]], + 'gesperrt' => [['blocked' => 1]], + 'Account abgelaufen' => [['payment_account' => now()->subDay()]], +]); + +it('liefert eine Vorschau mit allen Zählern', function () { + $source = transferConsultant([], 'Nadine'); + $childA = transferConsultant(['m_sponsor' => $source->id], 'ChildA'); + transferConsultant(['m_sponsor' => $childA->id], 'Grand'); + transferConsultant(['pre_sponsor' => $source->id, 'active' => 0], 'Parked'); + $customer = transferCustomer($source); + $deleted = transferCustomer($source); + $deleted->delete(); + Customer::create(['email' => $customer->billing_email, 'shopping_user_id' => $customer->id, 'member_id' => $source->id]); + + $preview = app(ConsultantTransferService::class)->preview($source); + + expect($preview['firstline_count'])->toBe(1) + ->and($preview['downline_count'])->toBe(2) + ->and($preview['pre_sponsor_count'])->toBe(1) + ->and($preview['customers_count'])->toBe(2) + ->and($preview['customers_deleted_count'])->toBe(1) + ->and($preview['portal_customers_count'])->toBe(1) + ->and($preview['firstline']->first()->id)->toBe($childA->id); +}); + +it('ist nur für Superadmins erreichbar', function () { + $admin = transferConsultant(['admin' => 2], 'Admin'); + + $this->actingAs($admin)->get(route('admin_user_transfer'))->assertForbidden(); +}); + +it('zeigt Superadmins die Seite mit Vorschau und führt die Übertragung nach Bestätigung aus', function () { + $superadmin = transferConsultant(['admin' => 3], 'Super'); + $source = transferConsultant([], 'Nadine'); + $target = transferConsultant([], 'Claudia'); + $child = transferConsultant(['m_sponsor' => $source->id], 'Child'); + $customer = transferCustomer($source); + + $this->actingAs($superadmin)->get(route('admin_user_transfer')) + ->assertOk() + ->assertSee(__('navigation.user_transfer')); + + $this->actingAs($superadmin) + ->post(route('admin_user_transfer_preview'), ['source_user_id' => $source->id, 'target_user_id' => $target->id]) + ->assertOk() + ->assertSee('2. Vorschau und Bestätigung') + ->assertSee($child->email); + + $this->actingAs($superadmin) + ->post(route('admin_user_transfer_execute'), ['source_user_id' => $source->id, 'target_user_id' => $target->id]) + ->assertRedirect() + ->assertSessionHasErrors('confirm_transfer'); + expect($child->fresh()->m_sponsor)->toBe($source->id); + + $this->actingAs($superadmin) + ->post(route('admin_user_transfer_execute'), ['source_user_id' => $source->id, 'target_user_id' => $target->id, 'confirm_transfer' => 1]) + ->assertRedirect(route('admin_user_transfer')) + ->assertSessionHas('alert-success'); + expect($child->fresh()->m_sponsor)->toBe($target->id); + expect($customer->fresh()->member_id)->toBe($target->id); +}); + +it('zeigt bei unzulässigem Ziel eine Fehlermeldung statt der Vorschau', function () { + $superadmin = transferConsultant(['admin' => 3], 'Super'); + $source = transferConsultant([], 'Nadine'); + $target = transferConsultant(['active' => 0], 'Claudia'); + + $this->actingAs($superadmin) + ->post(route('admin_user_transfer_preview'), ['source_user_id' => $source->id, 'target_user_id' => $target->id]) + ->assertRedirect(route('admin_user_transfer')) + ->assertSessionHas('alert-error', __('msg.transfer_error_target_inactive')); +});