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>
279 lines
11 KiB
PHP
279 lines
11 KiB
PHP
<?php
|
|
|
|
use App\Exceptions\ConsultantTransferException;
|
|
use App\Models\Country;
|
|
use App\Models\Customer;
|
|
use App\Models\ShoppingOrder;
|
|
use App\Models\ShoppingUser;
|
|
use App\Models\ShoppingUserMemberLog;
|
|
use App\Models\UserAbo;
|
|
use App\Models\UserAccount;
|
|
use App\Models\UserCleanUpLog;
|
|
use App\Models\UserShop;
|
|
use App\Services\ConsultantTransferService;
|
|
use App\User;
|
|
use Tests\TestCase;
|
|
|
|
uses(TestCase::class);
|
|
|
|
/**
|
|
* Ticket dev/_tickets/2026-09-08.md #1: Berater-Stamm übertragen.
|
|
*
|
|
* Die Admin-Routen werden host-abhängig geladen (RouteServiceProvider), daher
|
|
* wird die App hier mit dem CRM-Host neu gestartet und die In-Memory-DB migriert.
|
|
*/
|
|
beforeEach(function () {
|
|
$this->previousHttpHost = $_SERVER['HTTP_HOST'] ?? null;
|
|
$_SERVER['HTTP_HOST'] = config('domains.domains.crm.host');
|
|
$this->refreshApplication();
|
|
$this->artisan('migrate');
|
|
});
|
|
|
|
afterEach(function () {
|
|
// Host wieder zurücksetzen, sonst booten nachfolgende Tests mit den CRM-Routen.
|
|
if ($this->previousHttpHost === null) {
|
|
unset($_SERVER['HTTP_HOST']);
|
|
} else {
|
|
$_SERVER['HTTP_HOST'] = $this->previousHttpHost;
|
|
}
|
|
});
|
|
|
|
function transferConsultant(array $attributes = [], string $firstName = 'Test'): User
|
|
{
|
|
$account = UserAccount::create([
|
|
'first_name' => $firstName,
|
|
'last_name' => 'Berater',
|
|
'm_first_name' => $firstName,
|
|
'm_last_name' => 'Berater',
|
|
'm_account' => 'M'.random_int(1000, 9999),
|
|
]);
|
|
|
|
return User::forceCreate(array_merge([
|
|
'email' => strtolower($firstName).'-'.uniqid('', true).'@example.com',
|
|
'password' => bcrypt('secret'),
|
|
'lang' => 'de',
|
|
'active' => 1,
|
|
'blocked' => 0,
|
|
'wizard' => 100,
|
|
'payment_account' => now()->addYear(),
|
|
'account_id' => $account->id,
|
|
], $attributes))->fresh();
|
|
}
|
|
|
|
function transferCustomer(User $member, array $attributes = []): ShoppingUser
|
|
{
|
|
$country = Country::first() ?? Country::create([
|
|
'code' => 'DE', 'phone' => '49', 'en' => 'Germany', 'de' => 'Deutschland',
|
|
'es' => 'Alemania', 'fr' => 'Allemagne', 'it' => 'Germania', 'ru' => 'Германия',
|
|
]);
|
|
|
|
return ShoppingUser::create(array_merge([
|
|
'auth_user_id' => 0,
|
|
'member_id' => $member->id,
|
|
'number' => random_int(1000, 99999),
|
|
'billing_firstname' => 'Kunde',
|
|
'billing_lastname' => 'Test',
|
|
'billing_email' => 'kunde-'.uniqid('', true).'@example.com',
|
|
'billing_country_id' => $country->id,
|
|
'shipping_country_id' => $country->id,
|
|
], $attributes));
|
|
}
|
|
|
|
it('hängt die Firstline um, die Teilbäume darunter bleiben erhalten', function () {
|
|
$source = transferConsultant([], 'Nadine');
|
|
$target = transferConsultant([], 'Claudia');
|
|
$childA = transferConsultant(['m_sponsor' => $source->id], 'ChildA');
|
|
$childB = transferConsultant(['m_sponsor' => $source->id, 'active' => 0], 'ChildB');
|
|
$grandChild = transferConsultant(['m_sponsor' => $childA->id], 'Grand');
|
|
$unrelated = transferConsultant(['m_sponsor' => $target->id], 'Other');
|
|
|
|
$summary = app(ConsultantTransferService::class)->transfer($source, $target, 1);
|
|
|
|
expect($summary['firstline'])->toBe(2);
|
|
expect($childA->fresh()->m_sponsor)->toBe($target->id);
|
|
expect($childB->fresh()->m_sponsor)->toBe($target->id);
|
|
expect($grandChild->fresh()->m_sponsor)->toBe($childA->id);
|
|
expect($unrelated->fresh()->m_sponsor)->toBe($target->id);
|
|
expect($source->fresh()->m_sponsor)->toBeNull();
|
|
expect(UserCleanUpLog::where('inactive_sponsor_id', $source->id)->where('new_sponsor_id', $target->id)->count())->toBe(2);
|
|
});
|
|
|
|
it('überträgt Kunden, Kunden-Abos und Portal-Logins, lässt Bestellungen aber beim alten Berater', function () {
|
|
$source = transferConsultant([], 'Nadine');
|
|
$target = transferConsultant([], 'Claudia');
|
|
$customer = transferCustomer($source);
|
|
$deletedCustomer = transferCustomer($source);
|
|
$deletedCustomer->delete();
|
|
$foreignCustomer = transferCustomer($target);
|
|
|
|
$shop = UserShop::create(['user_id' => $source->id, 'name' => 'Shop', 'slug' => 'shop-'.uniqid()]);
|
|
$order = ShoppingOrder::create([
|
|
'shopping_user_id' => $customer->id,
|
|
'member_id' => $source->id,
|
|
'auth_user_id' => 0,
|
|
'user_shop_id' => $shop->id,
|
|
'country_id' => $customer->billing_country_id,
|
|
'total' => 10,
|
|
'txaction' => 'appointed',
|
|
'mode' => 'test',
|
|
]);
|
|
$abo = UserAbo::create([
|
|
'user_id' => null,
|
|
'member_id' => $source->id,
|
|
'shopping_user_id' => $customer->id,
|
|
'is_for' => 'ot',
|
|
'email' => $customer->billing_email,
|
|
'payone_userid' => 1,
|
|
'clearingtype' => 'cc',
|
|
'active' => true,
|
|
'status' => 2,
|
|
'abo_interval' => 1,
|
|
'next_date' => now()->addDays(3),
|
|
]);
|
|
$portal = Customer::create([
|
|
'email' => $customer->billing_email,
|
|
'shopping_user_id' => $customer->id,
|
|
'member_id' => $source->id,
|
|
'number' => $customer->number,
|
|
]);
|
|
|
|
$summary = app(ConsultantTransferService::class)->transfer($source, $target, 1);
|
|
|
|
expect($summary['customers'])->toBe(2);
|
|
expect($summary['abos'])->toBe(1);
|
|
expect($summary['portal_customers'])->toBe(1);
|
|
expect($customer->fresh()->member_id)->toBe($target->id);
|
|
expect(ShoppingUser::withTrashed()->find($deletedCustomer->id)->member_id)->toBe($target->id);
|
|
expect($foreignCustomer->fresh()->member_id)->toBe($target->id);
|
|
expect($abo->fresh()->member_id)->toBe($target->id);
|
|
expect($portal->fresh()->member_id)->toBe($target->id);
|
|
expect($order->fresh()->member_id)->toBe($source->id);
|
|
expect(ShoppingUserMemberLog::where('pre_member_id', $source->id)->where('new_member_id', $target->id)->count())->toBe(2);
|
|
});
|
|
|
|
it('überträgt geparkte Sponsor-Bezüge deaktivierter Berater', function () {
|
|
$source = transferConsultant([], 'Nadine');
|
|
$target = transferConsultant([], 'Claudia');
|
|
$parked = transferConsultant(['m_sponsor' => null, 'pre_sponsor' => $source->id, 'active' => 0], 'Parked');
|
|
|
|
$summary = app(ConsultantTransferService::class)->transfer($source, $target, 1);
|
|
|
|
expect($summary['pre_sponsor'])->toBe(1);
|
|
expect($parked->fresh()->pre_sponsor)->toBe($target->id);
|
|
});
|
|
|
|
it('erlaubt einen Zielberater aus der Firstline, der seinen eigenen Sponsor behält', function () {
|
|
$source = transferConsultant([], 'Nadine');
|
|
$target = transferConsultant(['m_sponsor' => $source->id], 'Claudia');
|
|
$sibling = transferConsultant(['m_sponsor' => $source->id], 'Sibling');
|
|
|
|
app(ConsultantTransferService::class)->transfer($source, $target, 1);
|
|
|
|
expect($target->fresh()->m_sponsor)->toBe($source->id);
|
|
expect($sibling->fresh()->m_sponsor)->toBe($target->id);
|
|
});
|
|
|
|
it('lehnt einen Zielberater unterhalb der Firstline ab, weil das einen Kreis erzeugen würde', function () {
|
|
$source = transferConsultant([], 'Nadine');
|
|
$child = transferConsultant(['m_sponsor' => $source->id], 'Child');
|
|
$target = transferConsultant(['m_sponsor' => $child->id], 'Claudia');
|
|
|
|
expect(fn () => app(ConsultantTransferService::class)->transfer($source, $target, 1))
|
|
->toThrow(ConsultantTransferException::class, __('msg.transfer_error_target_in_downline'));
|
|
expect($child->fresh()->m_sponsor)->toBe($source->id);
|
|
});
|
|
|
|
it('lehnt denselben Berater als Ziel ab', function () {
|
|
$source = transferConsultant([], 'Nadine');
|
|
|
|
expect(fn () => app(ConsultantTransferService::class)->transfer($source, $source, 1))
|
|
->toThrow(ConsultantTransferException::class, __('msg.transfer_error_same_user'));
|
|
});
|
|
|
|
it('lehnt einen inaktiven Zielberater ab', function (array $attributes) {
|
|
$source = transferConsultant([], 'Nadine');
|
|
$target = transferConsultant($attributes, 'Claudia');
|
|
$child = transferConsultant(['m_sponsor' => $source->id], 'Child');
|
|
|
|
try {
|
|
app(ConsultantTransferService::class)->transfer($source, $target, 1);
|
|
$this->fail('Exception erwartet');
|
|
} catch (ConsultantTransferException $e) {
|
|
expect($e->getMessage())->toBe(__('msg.transfer_error_target_inactive'));
|
|
}
|
|
|
|
expect($child->fresh()->m_sponsor)->toBe($source->id);
|
|
})->with([
|
|
'deaktiviert' => [['active' => 0]],
|
|
'gesperrt' => [['blocked' => 1]],
|
|
'Account abgelaufen' => [['payment_account' => now()->subDay()]],
|
|
]);
|
|
|
|
it('liefert eine Vorschau mit allen Zählern', function () {
|
|
$source = transferConsultant([], 'Nadine');
|
|
$childA = transferConsultant(['m_sponsor' => $source->id], 'ChildA');
|
|
transferConsultant(['m_sponsor' => $childA->id], 'Grand');
|
|
transferConsultant(['pre_sponsor' => $source->id, 'active' => 0], 'Parked');
|
|
$customer = transferCustomer($source);
|
|
$deleted = transferCustomer($source);
|
|
$deleted->delete();
|
|
Customer::create(['email' => $customer->billing_email, 'shopping_user_id' => $customer->id, 'member_id' => $source->id]);
|
|
|
|
$preview = app(ConsultantTransferService::class)->preview($source);
|
|
|
|
expect($preview['firstline_count'])->toBe(1)
|
|
->and($preview['downline_count'])->toBe(2)
|
|
->and($preview['pre_sponsor_count'])->toBe(1)
|
|
->and($preview['customers_count'])->toBe(2)
|
|
->and($preview['customers_deleted_count'])->toBe(1)
|
|
->and($preview['portal_customers_count'])->toBe(1)
|
|
->and($preview['firstline']->first()->id)->toBe($childA->id);
|
|
});
|
|
|
|
it('ist nur für Superadmins erreichbar', function () {
|
|
$admin = transferConsultant(['admin' => 2], 'Admin');
|
|
|
|
$this->actingAs($admin)->get(route('admin_user_transfer'))->assertForbidden();
|
|
});
|
|
|
|
it('zeigt Superadmins die Seite mit Vorschau und führt die Übertragung nach Bestätigung aus', function () {
|
|
$superadmin = transferConsultant(['admin' => 3], 'Super');
|
|
$source = transferConsultant([], 'Nadine');
|
|
$target = transferConsultant([], 'Claudia');
|
|
$child = transferConsultant(['m_sponsor' => $source->id], 'Child');
|
|
$customer = transferCustomer($source);
|
|
|
|
$this->actingAs($superadmin)->get(route('admin_user_transfer'))
|
|
->assertOk()
|
|
->assertSee(__('navigation.user_transfer'));
|
|
|
|
$this->actingAs($superadmin)
|
|
->post(route('admin_user_transfer_preview'), ['source_user_id' => $source->id, 'target_user_id' => $target->id])
|
|
->assertOk()
|
|
->assertSee('2. Vorschau und Bestätigung')
|
|
->assertSee($child->email);
|
|
|
|
$this->actingAs($superadmin)
|
|
->post(route('admin_user_transfer_execute'), ['source_user_id' => $source->id, 'target_user_id' => $target->id])
|
|
->assertRedirect()
|
|
->assertSessionHasErrors('confirm_transfer');
|
|
expect($child->fresh()->m_sponsor)->toBe($source->id);
|
|
|
|
$this->actingAs($superadmin)
|
|
->post(route('admin_user_transfer_execute'), ['source_user_id' => $source->id, 'target_user_id' => $target->id, 'confirm_transfer' => 1])
|
|
->assertRedirect(route('admin_user_transfer'))
|
|
->assertSessionHas('alert-success');
|
|
expect($child->fresh()->m_sponsor)->toBe($target->id);
|
|
expect($customer->fresh()->member_id)->toBe($target->id);
|
|
});
|
|
|
|
it('zeigt bei unzulässigem Ziel eine Fehlermeldung statt der Vorschau', function () {
|
|
$superadmin = transferConsultant(['admin' => 3], 'Super');
|
|
$source = transferConsultant([], 'Nadine');
|
|
$target = transferConsultant(['active' => 0], 'Claudia');
|
|
|
|
$this->actingAs($superadmin)
|
|
->post(route('admin_user_transfer_preview'), ['source_user_id' => $source->id, 'target_user_id' => $target->id])
|
|
->assertRedirect(route('admin_user_transfer'))
|
|
->assertSessionHas('alert-error', __('msg.transfer_error_target_inactive'));
|
|
});
|