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'));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue