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) <noreply@anthropic.com>
This commit is contained in:
parent
60f9985313
commit
ef7e333bd4
17 changed files with 902 additions and 0 deletions
11
app/Exceptions/ConsultantTransferException.php
Normal file
11
app/Exceptions/ConsultantTransferException.php
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* Wird geworfen, wenn die Übertragung eines Berater-Stamms nicht ausgeführt
|
||||
* werden kann (gleicher Berater, Zielberater inaktiv, Zielberater in der Downline).
|
||||
*/
|
||||
class ConsultantTransferException extends Exception {}
|
||||
105
app/Http/Controllers/AdminUserTransferController.php
Normal file
105
app/Http/Controllers/AdminUserTransferController.php
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Exceptions\ConsultantTransferException;
|
||||
use App\Http\Requests\UserTransfer\ExecuteConsultantTransferRequest;
|
||||
use App\Http\Requests\UserTransfer\PreviewConsultantTransferRequest;
|
||||
use App\Services\ConsultantTransferService;
|
||||
use App\User;
|
||||
use Auth;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
/**
|
||||
* Superadmin-Seite "Berater übertragen": kompletter Berater- und Kundenstamm
|
||||
* eines Beraters auf einen anderen Berater (dev/_tickets/2026-09-08.md, Ticket #1).
|
||||
*/
|
||||
class AdminUserTransferController extends Controller
|
||||
{
|
||||
public function __construct(protected ConsultantTransferService $transferService)
|
||||
{
|
||||
$this->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<string, mixed>
|
||||
*/
|
||||
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<User> $query
|
||||
* @return array<int|string, string>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\UserTransfer;
|
||||
|
||||
/**
|
||||
* Ausführung der Übertragung: zusätzlich zur Auswahl ist die bewusste
|
||||
* Bestätigung per Checkbox Pflicht — die Umhängung betrifft den ganzen Baum.
|
||||
*/
|
||||
class ExecuteConsultantTransferRequest extends PreviewConsultantTransferRequest
|
||||
{
|
||||
/**
|
||||
* @return array<string, array<int, mixed>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return array_merge(parent::rules(), [
|
||||
'confirm_transfer' => ['accepted'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return array_merge(parent::messages(), [
|
||||
'confirm_transfer.accepted' => __('msg.transfer_confirm_checkbox_missing'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\UserTransfer;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* Auswahl von Quell- und Zielberater für die Übertragung eines Berater-Stamms
|
||||
* (dev/_tickets/2026-09-08.md, Ticket #1).
|
||||
*/
|
||||
class PreviewConsultantTransferRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
// Die Route liegt hinter der superadmin-Middleware.
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, mixed>>
|
||||
*/
|
||||
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<string, string>
|
||||
*/
|
||||
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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
247
app/Services/ConsultantTransferService.php
Normal file
247
app/Services/ConsultantTransferService.php
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\ConsultantTransferException;
|
||||
use App\Models\Customer;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Models\ShoppingUserMemberLog;
|
||||
use App\Models\UserAbo;
|
||||
use App\Models\UserCleanUpLog;
|
||||
use App\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Überträgt den kompletten Berater- und Kundenstamm eines Beraters (Quelle)
|
||||
* auf einen anderen Berater (Ziel), z. B. wenn ein Berater aufhört
|
||||
* (Ticket dev/_tickets/2026-09-08.md, #1).
|
||||
*
|
||||
* Übertragen werden:
|
||||
* - die Firstline (users.m_sponsor = Quelle) — die darunter liegenden Teilbäume
|
||||
* folgen automatisch, da sie an ihrer Firstline hängen
|
||||
* - geparkte Sponsor-Bezüge deaktivierter Berater (users.pre_sponsor = Quelle),
|
||||
* damit eine spätere Reaktivierung unter dem Zielberater landet
|
||||
* - alle Kunden-Datensätze (shopping_users.member_id = Quelle, auch soft-deleted)
|
||||
* - alle Kunden-Abos (user_abos.member_id = Quelle), damit künftige Abo-Bestellungen
|
||||
* dem Zielberater zugeordnet werden
|
||||
* - Portal-Logins der Kunden (customers.member_id = Quelle)
|
||||
*
|
||||
* Bewusst NICHT übertragen werden bestehende Bestellungen (shopping_orders.member_id)
|
||||
* und Umsatz-/Provisionsdaten: Sie sind Historie und bleiben beim ursprünglichen Berater.
|
||||
*
|
||||
* Jede Umhängung wird wie bei der automatischen User-Bereinigung protokolliert
|
||||
* (UserCleanUpLog, ShoppingUserMemberLog), sodass sie in der User-Bereinigung
|
||||
* unter "Downline-Übertragungen" / "Kunden-Übertragungen" nachvollziehbar ist.
|
||||
*/
|
||||
class ConsultantTransferService
|
||||
{
|
||||
public const SYSLOG_ACTION = 'consultant-transfer';
|
||||
|
||||
/**
|
||||
* Vorschau: Was würde bei einer Übertragung von $source bewegt?
|
||||
*
|
||||
* @return array{
|
||||
* firstline: Collection<int, User>,
|
||||
* 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<string, int> 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<int, int>
|
||||
*/
|
||||
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<User>
|
||||
*/
|
||||
protected function firstlineQuery(User $source)
|
||||
{
|
||||
return User::withTrashed()
|
||||
->where('m_sponsor', $source->id)
|
||||
->where('id', '!=', $source->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Builder<User>
|
||||
*/
|
||||
protected function preSponsorQuery(User $source)
|
||||
{
|
||||
return User::withTrashed()
|
||||
->where('pre_sponsor', $source->id)
|
||||
->where('id', '!=', $source->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $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'));
|
||||
}
|
||||
}
|
||||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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).',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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).',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
146
resources/views/admin/user/transfer/index.blade.php
Normal file
146
resources/views/admin/user/transfer/index.blade.php
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
@extends('layouts.layout-2')
|
||||
|
||||
@section('content')
|
||||
<h4 class="font-weight-bold py-2 mb-2">
|
||||
<i class="ion ion-md-git-network"></i> {{ __('navigation.user_transfer') }}
|
||||
</h4>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col">
|
||||
<div class="alert alert-info">
|
||||
<strong>Info:</strong> Überträgt den <b>gesamten Berater- und Kundenstamm</b> 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
|
||||
<a href="{{ route('admin_user_cleanup_logs') }}">User-Bereinigung</a> protokolliert.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($errors->any())
|
||||
<div class="alert alert-danger">
|
||||
<ul class="mb-0">
|
||||
@foreach ($errors->all() as $error)
|
||||
<li>{{ $error }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="card mb-4">
|
||||
<h6 class="card-header">1. Berater auswählen</h6>
|
||||
<div class="card-body">
|
||||
{!! Form::open(['route' => 'admin_user_transfer_preview', 'method' => 'post']) !!}
|
||||
<div class="form-row">
|
||||
<div class="form-group col-md-6">
|
||||
<label class="form-label" for="source_user_id">Von Berater (gibt den Stamm ab)</label>
|
||||
{!! 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',
|
||||
]) !!}
|
||||
</div>
|
||||
<div class="form-group col-md-6">
|
||||
<label class="form-label" for="target_user_id">An Berater (übernimmt den Stamm)</label>
|
||||
{!! 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',
|
||||
]) !!}
|
||||
<small class="form-text text-muted">Nur aktive, nicht gesperrte Berater mit gültigem Account.</small>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Vorschau anzeigen</button>
|
||||
{!! Form::close() !!}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($preview && $source && $target)
|
||||
<div class="card mb-4">
|
||||
<h6 class="card-header">2. Vorschau und Bestätigung</h6>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="text-muted small">Von</div>
|
||||
<strong>{{ \App\Http\Controllers\AdminUserTransferController::userLabel($source) }}</strong>
|
||||
<div class="small">
|
||||
Status: {{ $source->active ? 'aktiv' : 'deaktiviert' }},
|
||||
Account bis {{ $source->payment_account ? \Carbon\Carbon::parse($source->payment_account)->format('d.m.Y') : '-' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="text-muted small">An</div>
|
||||
<strong>{{ \App\Http\Controllers\AdminUserTransferController::userLabel($target) }}</strong>
|
||||
@if ((int) $target->m_sponsor === (int) $source->id)
|
||||
<div class="small text-warning">Der Zielberater ist selbst in der Firstline des Quellberaters. Er behält seinen Sponsor und bekommt die übrigen Firstline-Berater als Downline.</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-bordered mb-3" id="transfer-preview">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th style="width: 50%">Firstline-Berater (werden direkt umgehängt)</th>
|
||||
<td>{{ $preview['firstline_count'] }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Gesamte Downline (alle Ebenen, folgt automatisch)</th>
|
||||
<td>{{ $preview['downline_count'] }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Deaktivierte Berater mit geparktem Sponsor-Bezug</th>
|
||||
<td>{{ $preview['pre_sponsor_count'] }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Kunden-Datensätze (davon gelöscht)</th>
|
||||
<td>{{ $preview['customers_count'] }} ({{ $preview['customers_deleted_count'] }})</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Kunden-Abos (davon aktiv)</th>
|
||||
<td>{{ $preview['abos_count'] }} ({{ $preview['abos_active_count'] }})</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Kunden-Portal-Logins</th>
|
||||
<td>{{ $preview['portal_customers_count'] }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@if ($preview['firstline']->isNotEmpty())
|
||||
<div class="text-muted small mb-1">Firstline-Berater:</div>
|
||||
<ul class="mb-3">
|
||||
@foreach ($preview['firstline']->take(50) as $child)
|
||||
<li>
|
||||
{{ \App\Http\Controllers\AdminUserTransferController::userLabel($child) }}
|
||||
@if ($child->id === $target->id) <span class="badge badge-warning">Zielberater, bleibt</span> @endif
|
||||
@if ($child->deleted_at) <span class="badge badge-secondary">gelöscht</span> @elseif (! $child->active) <span class="badge badge-secondary">deaktiviert</span> @endif
|
||||
</li>
|
||||
@endforeach
|
||||
@if ($preview['firstline']->count() > 50)
|
||||
<li>… und {{ $preview['firstline']->count() - 50 }} weitere</li>
|
||||
@endif
|
||||
</ul>
|
||||
@endif
|
||||
|
||||
{!! Form::open(['route' => 'admin_user_transfer_execute', 'method' => 'post']) !!}
|
||||
{!! Form::hidden('source_user_id', $source->id) !!}
|
||||
{!! Form::hidden('target_user_id', $target->id) !!}
|
||||
<label class="custom-control custom-checkbox mb-3">
|
||||
<input type="checkbox" class="custom-control-input" name="confirm_transfer" value="1">
|
||||
<span class="custom-control-label">Ich habe die Vorschau geprüft und möchte den gesamten Berater- und Kundenstamm von <b>{{ $source->email }}</b> an <b>{{ $target->email }}</b> übertragen.</span>
|
||||
</label>
|
||||
<div>
|
||||
<button type="submit" class="btn btn-danger">Übertragung ausführen</button>
|
||||
<a href="{{ route('admin_user_transfer') }}" class="btn btn-default">Abbrechen</a>
|
||||
</div>
|
||||
{!! Form::close() !!}
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endsection
|
||||
|
|
@ -645,6 +645,12 @@
|
|||
<div>{{ __('navigation.user_cleanup') }}</div>
|
||||
</a>
|
||||
</li>
|
||||
<li class="sidenav-item{{ Request::is('admin/user/transfer*') ? ' active' : '' }}">
|
||||
<a href="{{ route('admin_user_transfer') }}" class="sidenav-link"><i
|
||||
class="sidenav-icon ion ion-md-git-network"></i>
|
||||
<div>{{ __('navigation.user_transfer') }}</div>
|
||||
</a>
|
||||
</li>
|
||||
<li class="sidenav-item{{ Request::is('superadmin/tools', 'superadmin/tool/*') ? ' active' : '' }}">
|
||||
<a href="{{ route('superadmin_tools') }}" class="sidenav-link"><i
|
||||
class="sidenav-icon ion ion-ios-cog"></i>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
279
tests/Feature/ConsultantTransferTest.php
Normal file
279
tests/Feature/ConsultantTransferTest.php
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
<?php
|
||||
|
||||
use App\Exceptions\ConsultantTransferException;
|
||||
use App\Models\Country;
|
||||
use App\Models\Customer;
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Models\ShoppingUserMemberLog;
|
||||
use App\Models\UserAbo;
|
||||
use App\Models\UserAccount;
|
||||
use App\Models\UserCleanUpLog;
|
||||
use App\Models\UserShop;
|
||||
use App\Services\ConsultantTransferService;
|
||||
use App\User;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
/**
|
||||
* Ticket dev/_tickets/2026-09-08.md #1: Berater-Stamm übertragen.
|
||||
*
|
||||
* Die Admin-Routen werden host-abhängig geladen (RouteServiceProvider), daher
|
||||
* wird die App hier mit dem CRM-Host neu gestartet und die In-Memory-DB migriert.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
$this->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'));
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue