suspendDays(), (int) config('mivita.cleanup_delete_days', 28)); } /** * Tag der Pause: Ablauftag + `cleanup_suspend_days` Kalendertage, im Nachtlauf dieses * Tages. Die Fristen zählen in Kalendertagen, nicht ab der Uhrzeit des Ablaufs — * sonst läge der Nachtlauf je nach Uhrzeit einen Tag später als das Datum in der Mail. */ public function suspensionDateFor(User $user): ?Carbon { return $user->payment_account ? Carbon::parse($user->payment_account)->startOfDay()->addDays($this->suspendDays()) : null; } /** * Letzter Tag, an dem der Berater durch Zahlung alles zurückholen kann. Im Nachtlauf * des Folgetags (Ablauftag + `cleanup_delete_days`) folgt der harte Cut. */ public function deadlineFor(User $user): ?Carbon { return $user->payment_account ? Carbon::parse($user->payment_account)->startOfDay()->addDays($this->deleteDays() - 1)->endOfDay() : null; } /** * Wer vor diesem Zeitpunkt abgelaufen ist, ist heute an der Reihe: der Ablauftag * liegt mindestens `$days` Kalendertage zurück. Bei 0 Tagen gilt jetzt. */ private function expiredBefore(int $days): Carbon { return $days > 0 ? now()->startOfDay()->subDays($days - 1) : now(); } /** * Aktive Berater, deren Mitgliedschaft seit mehr als `cleanup_suspend_days` abgelaufen ist. * * @return Collection */ public function suspensionCandidates(): Collection { return User::query() ->with('account') ->where('active', true) ->whereNotNull('m_sponsor') ->where('payment_account', '<', $this->expiredBefore($this->suspendDays())) ->get(); } /** * Berater (keine Admins), deren Mitgliedschaft seit mehr als `cleanup_delete_days` abgelaufen ist. * * @return Collection */ public function terminationCandidates(): Collection { return User::query() ->with('account') ->where('admin', 0) ->where('payment_account', '<', $this->expiredBefore($this->deleteDays())) ->get(); } /** * Was ein Lauf für diesen Berater bewegen würde (Trockenlauf). * * @return array{team: int, customers: int, abos: int, portal_customers: int, parked: int} */ public function preview(User $user): array { return [ 'team' => User::where('m_sponsor', $user->id)->count(), 'customers' => ShoppingUser::withTrashed()->where('member_id', $user->id)->count(), 'abos' => UserAbo::withTrashed()->where('member_id', $user->id)->count(), 'portal_customers' => Customer::where('member_id', $user->id)->count(), 'parked' => ConsultantSuspensionTransfer::openFor($user->id)->count(), ]; } /** * Stufe 1: Berater pausieren, Team und Kundendaten umkehrbar an den Sponsor. * * @return array{sponsor_id: int, team: int, customers: int, abos: int, portal_customers: int}|null null, wenn kein Sponsor gefunden wurde */ public function suspend(User $user, string $info = ''): ?array { $user = User::with('account')->find($user->id); if (! $user) { return null; } $sponsor = $user->m_sponsor ? UserUtil::findNextActiveSponsor($user->m_sponsor) : null; if (! $sponsor || (int) $sponsor->id === (int) $user->id) { Log::channel('cleanup')->error('ConsultantSuspension::suspend kein aktiver Sponsor fuer user_id '.$user->id.' '.$info); return null; } $summary = DB::transaction(function () use ($user, $sponsor): array { $team = User::where('m_sponsor', $user->id)->count(); UserUtil::setNewSponsorToChilds($user->id, $sponsor->id); $moved = $this->moveCustomerData($user, $sponsor); UserUtil::deactiveUser($user); return ['sponsor_id' => (int) $sponsor->id, 'team' => $team] + $moved; }); Log::channel('cleanup')->info('ConsultantSuspension::suspend '.$info.' : '.json_encode(['user_id' => $user->id, 'email' => $user->email] + $summary)); $this->mailSponsor($sponsor, new SponsorSuspensionNoticeMail($sponsor, $user, $this->deadlineFor($user))); return $summary; } /** * Rückgabe nach Zahlung: Berater aktivieren, Team und Kundendaten zurückholen. * * @return array{customers: int, abos: int, portal_customers: int, kept: int} */ public function reinstate(User $user, string $info = ''): array { $user = User::find($user->id); if (! $user) { return ['customers' => 0, 'abos' => 0, 'portal_customers' => 0, 'kept' => 0]; } $summary = DB::transaction(function () use ($user): array { UserUtil::reactiveUser($user); UserUtil::resetChildsToSponsor($user->id); return $this->returnCustomerData($user); }); Log::channel('cleanup')->info('ConsultantSuspension::reinstate '.$info.' : '.json_encode(['user_id' => $user->id, 'email' => $user->email] + $summary)); return $summary; } /** * Stufe 2: harter Cut. Was noch am Berater hängt, geht endgültig an den Sponsor, * der Berater wird dessen Kunde und gelöscht. Danach gehen die Listen an jeden * Sponsor, der Team oder Kunden dieses Beraters hält. * * @return array{sponsor_id: int, team: int, customers: int, abos: int, portal_customers: int}|null */ public function terminate(User $user): ?array { $user = User::with('account')->find($user->id); if (! $user) { return null; } $sponsor = UserUtil::findNextActiveSponsor($user->id); if (! $sponsor || (int) $sponsor->id === (int) $user->id) { Log::channel('cleanup')->error('ConsultantSuspension::terminate kein aktiver Sponsor fuer user_id '.$user->id); return null; } if (! $user->account) { Log::channel('cleanup')->error('ConsultantSuspension::terminate ohne Account-Daten, uebersprungen: user_id '.$user->id); return null; } $summary = DB::transaction(function () use ($user, $sponsor): array { $team = User::where('m_sponsor', $user->id)->count(); UserUtil::setNewSponsorToChilds($user->id, $sponsor->id); $moved = $this->moveCustomerData($user, $sponsor); $this->convertToCustomer($user, $sponsor); UserUtil::deleteUser($user); return ['sponsor_id' => (int) $sponsor->id, 'team' => $team] + $moved; }); Log::channel('cleanup')->info('ConsultantSuspension::terminate : '.json_encode(['user_id' => $user->id, 'email' => $user->email] + $summary)); foreach ($this->transferListsBySponsor($user, $sponsor) as $sponsorId => $lists) { $receiver = User::with('account')->find($sponsorId); if ($receiver) { $this->mailSponsor($receiver, new SponsorTransferMail( $receiver, $user, $lists['customers'], $lists['team'], (int) $sponsorId === (int) $sponsor->id, )); } } return $summary; } /** * Kunden, Kundenabos und Portal-Logins des Beraters an den Sponsor, je Datensatz protokolliert. * * @return array{customers: int, abos: int, portal_customers: int} */ private function moveCustomerData(User $user, User $sponsor): array { $customers = 0; foreach (ShoppingUser::withTrashed()->where('member_id', $user->id)->get() as $customer) { $this->logTransfer($user, $sponsor, ConsultantSuspensionTransfer::TYPE_SHOPPING_USER, $customer->id); ShoppingUserMemberLog::create([ 'pre_member_id' => $user->id, 'shopping_user_id' => $customer->id, 'new_member_id' => $sponsor->id, ]); $customer->member_id = $sponsor->id; $customer->save(); $customers++; } $abos = 0; foreach (UserAbo::withTrashed()->where('member_id', $user->id)->pluck('id') as $aboId) { $this->logTransfer($user, $sponsor, ConsultantSuspensionTransfer::TYPE_USER_ABO, (int) $aboId); $abos++; } UserAbo::withTrashed()->where('member_id', $user->id)->update(['member_id' => $sponsor->id]); $portal = 0; foreach (Customer::where('member_id', $user->id)->pluck('id') as $customerId) { $this->logTransfer($user, $sponsor, ConsultantSuspensionTransfer::TYPE_CUSTOMER, (int) $customerId); $portal++; } Customer::where('member_id', $user->id)->update(['member_id' => $sponsor->id]); return ['customers' => $customers, 'abos' => $abos, 'portal_customers' => $portal]; } /** * Gibt alle offenen Umhängungen zurück — aber nur Datensätze, die noch bei dem * Sponsor liegen, der sie bekommen hat. Wurde ein Kunde inzwischen von Hand * woanders hin übertragen, bleibt er dort. * * @return array{customers: int, abos: int, portal_customers: int, kept: int} */ private function returnCustomerData(User $user): array { $summary = ['customers' => 0, 'abos' => 0, 'portal_customers' => 0, 'kept' => 0]; foreach (ConsultantSuspensionTransfer::openFor($user->id)->get() as $transfer) { $record = match ($transfer->record_type) { ConsultantSuspensionTransfer::TYPE_SHOPPING_USER => ShoppingUser::withTrashed()->find($transfer->record_id), ConsultantSuspensionTransfer::TYPE_USER_ABO => UserAbo::withTrashed()->find($transfer->record_id), ConsultantSuspensionTransfer::TYPE_CUSTOMER => Customer::find($transfer->record_id), default => null, }; if ($record && (int) $record->member_id === $transfer->sponsor_id) { if ($record instanceof ShoppingUser) { ShoppingUserMemberLog::create([ 'pre_member_id' => $transfer->sponsor_id, 'shopping_user_id' => $record->id, 'new_member_id' => $user->id, ]); } // Ohne Model-Events: an Abo und Portal-Login haengt sonst Status-/Audit-Logik, // die hier nichts zu suchen hat — es aendert sich nur die Zuordnung. $record->newQuery()->withoutGlobalScopes()->whereKey($record->getKey())->update(['member_id' => $user->id]); $summary[match ($transfer->record_type) { ConsultantSuspensionTransfer::TYPE_SHOPPING_USER => 'customers', ConsultantSuspensionTransfer::TYPE_USER_ABO => 'abos', default => 'portal_customers', }]++; } else { $summary['kept']++; } $transfer->returned_at = now(); $transfer->save(); } return $summary; } /** * Der Berater wird Kunde des Sponsors. Liegt schon ein Kundendatensatz mit seiner * E-Mail oder seinem Login beim Sponsor (z. B. aus seinen eigenen Bestellungen, der * eben mit umgezogen ist), wird kein zweiter angelegt. */ private function convertToCustomer(User $user, User $sponsor): void { $exists = ShoppingUser::query() ->where('member_id', $sponsor->id) ->where(function ($query) use ($user): void { $query->where('auth_user_id', $user->id) ->orWhere('billing_email', $user->email); }) ->exists(); if (! $exists) { UserUtil::setUserToClient($user->id, $sponsor->id); } } /** * Listen für die Sponsor-Mail beim harten Cut, gruppiert nach dem Sponsor, der die * Datensätze heute hält. Der Berater selbst steht beim Sponsor des harten Cuts. * * @return array, team: list}> */ private function transferListsBySponsor(User $user, User $sponsor): array { $lists = [(int) $sponsor->id => ['customers' => [], 'team' => []]]; // Nur Datensaetze, die noch bei dem Sponsor liegen, der sie ueber diesen Ablauf // bekommen hat — von Hand weiter uebertragene Kunden gehoeren nicht in seine Liste. $sponsorByCustomer = ConsultantSuspensionTransfer::openFor($user->id) ->where('record_type', ConsultantSuspensionTransfer::TYPE_SHOPPING_USER) ->pluck('sponsor_id', 'record_id'); $customers = ShoppingUser::query()->whereIn('id', $sponsorByCustomer->keys())->orderBy('billing_lastname')->get(); foreach ($customers as $customer) { $holder = (int) $customer->member_id; if ($holder !== (int) $sponsorByCustomer->get($customer->id)) { continue; } $lists[$holder] ??= ['customers' => [], 'team' => []]; $lists[$holder]['customers'][] = [ 'first_name' => (string) $customer->billing_firstname, 'last_name' => (string) $customer->billing_lastname, 'phone' => (string) $customer->billing_phone, 'email' => (string) $customer->billing_email, ]; } $sponsorByMember = UserCleanUpLog::where('inactive_sponsor_id', $user->id)->pluck('new_sponsor_id', 'child_user_id'); $team = User::query()->with('account')->whereIn('id', $sponsorByMember->keys())->get(); foreach ($team as $member) { $holder = (int) ($member->m_sponsor ?: $member->pre_sponsor); if (! $holder || $holder !== (int) $sponsorByMember->get($member->id)) { continue; } $lists[$holder] ??= ['customers' => [], 'team' => []]; $lists[$holder]['team'][] = [ 'first_name' => (string) $member->account?->first_name, 'last_name' => (string) $member->account?->last_name, 'phone' => trim((string) $member->account?->getPhoneNumber()), 'email' => (string) $member->email, ]; } return $lists; } private function logTransfer(User $user, User $sponsor, string $type, int $recordId): void { ConsultantSuspensionTransfer::create([ 'consultant_id' => $user->id, 'sponsor_id' => $sponsor->id, 'record_type' => $type, 'record_id' => $recordId, ]); } /** * Keine Mail an das Firmenkonto; ein Mailfehler darf den Lauf nicht abbrechen. */ private function mailSponsor(User $sponsor, Mailable $mail): void { if ((int) $sponsor->id === (int) config('mivita.company_user_id') || ! $sponsor->email) { return; } try { Mail::to($sponsor->email)->locale($sponsor->getLocale())->send($mail); } catch (\Throwable $e) { Log::channel('cleanup')->error('ConsultantSuspension Sponsor-Mail fehlgeschlagen fuer sponsor_id '.$sponsor->id.': '.$e->getMessage()); } } }