Entwicklungsplan September, B2 (Protokoll 08.09., Rueckmeldung 14.09., R3): - Ablauftag + 2 Kalendertage (Nachtlauf): Team, Kunden, Kundenabos und Portal-Logins gehen umkehrbar an den naechsten aktiven Sponsor, jede Umhaengung in consultant_suspension_transfers protokolliert. Kurzinfo an den Sponsor ohne Listen, keine Mail an das Firmenkonto. - Zahlung bis Tag 27: Berater aktiv, Team und Kundendaten zurueck (nur was noch beim Sponsor liegt); Umsaetze der Zwischenzeit bleiben dort. - Tag 28: was noch am Berater haengt geht endgueltig an den Sponsor, Berater wird dessen Kunde (ohne Duplikat) und geloescht; Listen-Mail mit Vorname, Nachname, Telefon, E-Mail an jeden haltenden Sponsor. - Cron, Zahlungseingang und Admin-Ablaufdatum laufen ueber ConsultantSuspensionService; user:cleanup --dry-run listet beide Stufen. - Mail 35 und Dashboard nennen Pausentag und Frist; Fristen in config/mivita.php (cleanup_suspend_days, cleanup_delete_days). - Nebenbefunde: Kernel referenziert UserCleanUp, user:restore aus der User-Bereinigung laeuft nur noch einmal. - Testschema: shopping_users.auth_user_id nullable wie auf Produktion (Migration aendert nur, wo die Spalte noch NOT NULL ist). - Uebersicht fuer die Kundin: dev/_tickets/2026-09-23-mitgliedschaft- ablauf-uebersicht.md. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
416 lines
17 KiB
PHP
416 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 = $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<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;
|
|
}
|
|
|
|
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());
|
|
}
|
|
}
|
|
}
|