mivita/app/Services/ConsultantSuspensionService.php
Kevin 42d2a9c0ab Berater faellt aus (B2): Berater ohne Sponsor gehen an das Firmenkonto
Der Dry-Run auf Produktion (25.09.) listet einen Berater, der ohne Sponsor
registriert wurde (m_sponsor und pre_sponsor leer). terminate() haette ihn
jede Nacht mit "kein aktiver Sponsor" uebersprungen. Pausieren und harter
Cut nehmen jetzt den naechsten aktiven Sponsor ueber m_sponsor/pre_sponsor
und sonst das Firmenkonto (ohne Mail).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 10:38:22 +00:00

432 lines
17 KiB
PHP

<?php
namespace App\Services;
use App\Mail\SponsorSuspensionNoticeMail;
use App\Mail\SponsorTransferMail;
use App\Models\ConsultantSuspensionTransfer;
use App\Models\Customer;
use App\Models\ShoppingUser;
use App\Models\ShoppingUserMemberLog;
use App\Models\UserAbo;
use App\Models\UserCleanUpLog;
use App\User;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Mail\Mailable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
/**
* Ablauf, wenn ein Berater seine Mitgliedschaft nicht verlängert
* (Entwicklungsplan dev/_tickets/entwicklungsplan-protokoll-september-2026.md, B2).
*
* 1. Pausieren ({@see suspend()}), Ablauf + `mivita.cleanup_suspend_days`: Team, Kunden,
* Kundenabos und Portal-Logins gehen an den nächsten aktiven Sponsor, der Berater
* fällt aus dem Provisionsbaum. Alles wird protokolliert und ist umkehrbar. Der
* Sponsor bekommt eine Kurzinfo ohne Listen.
* 2. Rückgabe ({@see reinstate()}): Zahlt der Berater vor dem harten Cut, bekommt er
* Team und Kunden zurück — nur Datensätze, die noch beim Sponsor liegen. Umsätze
* der Zwischenzeit bleiben beim Sponsor, weil Punkte zum Bestellzeitpunkt gebucht
* werden.
* 3. Harter Cut ({@see terminate()}), Ablauf + `mivita.cleanup_delete_days`: der Berater
* wird Kunde seines Sponsors und gelöscht; der Sponsor bekommt die Listen.
*
* Cron (`user:cleanup`), Zahlungseingang ({@see Payment::paymentStatusPaidAction()})
* und das Ablaufdatum im Admin laufen über diese Klasse, damit es nur einen Weg gibt.
*/
class ConsultantSuspensionService
{
public function suspendDays(): int
{
return max(0, (int) config('mivita.cleanup_suspend_days', 2));
}
public function deleteDays(): int
{
return max($this->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<int, User>
*/
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<int, User>
*/
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 = $this->sponsorFor($user);
if (! $sponsor) {
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 = $this->sponsorFor($user);
if (! $sponsor) {
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<int, array{customers: list<array{first_name: string, last_name: string, phone: string, email: string}>, team: list<array{first_name: string, last_name: string, phone: string, email: string}>}>
*/
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;
}
/**
* Nächster aktiver Sponsor über m_sponsor bzw. pre_sponsor. Berater ganz ohne
* Sponsor (ohne Sponsor registriert) gehen an das Firmenkonto.
*/
private function sponsorFor(User $user): ?User
{
$sponsorId = $user->m_sponsor ?: $user->pre_sponsor;
$sponsor = $sponsorId ? UserUtil::findNextActiveSponsor($sponsorId) : null;
if (! $sponsor || (int) $sponsor->id === (int) $user->id) {
$sponsor = User::find((int) config('mivita.company_user_id'));
}
return $sponsor && (int) $sponsor->id !== (int) $user->id ? $sponsor : null;
}
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());
}
}
}