mivita/app/Services/ConsultantTransferService.php
Kevin ef7e333bd4 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>
2026-09-23 15:43:58 +00:00

247 lines
8.8 KiB
PHP

<?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'));
}
}