mivita/tests/Feature/RecognitionServiceTest.php
Kevin Adametz 7bc551ca1f Anerkennungen: firmenweite Monats-Ranglisten im Salescenter
- Neue Seite Anerkennungen (/user/recognitions, Menue unter News Archiv) fuer
  alle Berater mit aktivem Konto: oben die Anerkennungen des Vormonats in den
  fuenf Kategorien der Monatsmail, Filter zurueck bis Januar 2026; darunter
  Live-Ranglisten (Top 10 je Kategorie) des laufenden Monats, 15 Min. Cache.
- Kategorien: Top-Recruiter (ab 2 Neupartnern), neue Kunden-Abos,
  Kundenabo-Punkte, Kunden-Abos gesamt, Gesamtpunkte (1.000-Punkte-Shop,
  mit Platz 4). Regeln wie TopResultsService/VIP-Statistik, nur firmenweit.
- Firmenkonto (6) und Grosskundin (3) nie aufgefuehrt:
  config mivita.recognition_excluded_user_ids.
- Abgeschlossene Monate sind bis zum 3. des Folgemonats vorlaeufig und werden
  dann eingefroren: neue Tabelle recognition_snapshots, Command
  recognitions:freeze (taeglich 06:15, idempotent, holt offene Monate nach).
- September-Anerkennungen firmenweit geprueft und korrigiert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:13:22 +00:00

253 lines
9.6 KiB
PHP

<?php
use App\Models\RecognitionSnapshot;
use App\Models\ShoppingOrder;
use App\Models\UserAbo;
use App\Models\UserAccount;
use App\Models\UserSalesVolume;
use App\Services\Team\RecognitionService;
use App\User;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
Carbon::setTestNow(Carbon::parse('2026-10-09 10:00:00'));
Cache::flush();
config(['mivita.recognition_excluded_user_ids' => []]);
});
afterEach(function () {
Carbon::setTestNow();
});
function recMakeUser(string $firstName, string $lastName = 'Partner', ?int $sponsorId = null, ?string $partnerPaidAt = null): User
{
$account = UserAccount::forceCreate([
'first_name' => $firstName,
'last_name' => $lastName,
]);
return User::forceCreate([
'email' => 'rec-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'm_level' => 1,
'm_sponsor' => $sponsorId,
'payment_account' => now()->addYear(),
'active_date' => $partnerPaidAt,
'partner_paid_at' => $partnerPaidAt,
'account_id' => $account->id,
]);
}
function recAddVolume(int $userId, float $points, int $status, int $month = 9, ?int $shoppingOrderId = null): void
{
UserSalesVolume::forceCreate([
'user_id' => $userId,
'month' => $month,
'year' => 2026,
'points' => $points,
'status' => $status,
'shopping_order_id' => $shoppingOrderId,
]);
}
function recAddCustomerAboPoints(int $userId, float $points, int $month = 9): void
{
$order = ShoppingOrder::forceCreate([
'shopping_user_id' => 0,
'auth_user_id' => 0,
'country_id' => 0,
'user_shop_id' => 0,
'member_id' => $userId,
'is_abo' => true,
]);
recAddVolume($userId, $points, 2, $month, $order->id);
}
function recMakeCustomerAbo(int $consultantId, string $startDate): UserAbo
{
return UserAbo::create([
'user_id' => 0,
'member_id' => $consultantId,
'shopping_user_id' => 0,
'email' => 'rec-abo-'.uniqid('', true).'@example.com',
'is_for' => 'ot',
'payone_userid' => 0,
'clearingtype' => 'cc',
'amount' => 5000,
'active' => true,
'status' => 2,
'abo_interval' => 5,
'start_date' => $startDate,
'next_date' => '2026-11-05',
]);
}
/**
* @param array<int, array<string, mixed>> $podium
* @return array<int, array{place: int, value: float|int, names: array<int, string>}>
*/
function recPlaces(array $podium): array
{
return array_map(fn (array $place) => [
'place' => $place['place'],
'value' => $place['value'],
'names' => array_map(fn (array $member) => $member['first_name'], $place['members']),
], $podium);
}
it('rankt alle fuenf Kategorien firmenweit, unabhaengig vom Team', function (): void {
$anna = recMakeUser('Anna');
$ben = recMakeUser('Ben');
recMakeUser('Neu1', 'Partner', $anna->id, '2026-09-10');
recMakeUser('Neu2', 'Partner', $anna->id, '2026-09-20');
recMakeUser('Alt', 'Partner', $anna->id, '2026-08-20');
recMakeCustomerAbo($ben->id, '2026-09-05');
recMakeCustomerAbo($ben->id, '2026-09-15');
recMakeCustomerAbo($ben->id, '2026-06-01');
recMakeCustomerAbo($anna->id, '2026-09-02');
recAddCustomerAboPoints($ben->id, 120.5);
recAddCustomerAboPoints($anna->id, 80);
recAddVolume($anna->id, 900, 1);
recAddVolume($ben->id, 50, 1, 8);
$categories = app(RecognitionService::class)->recognitions(9, 2026)['categories'];
expect(recPlaces($categories['recruiters']))->toBe([['place' => 1, 'value' => 2, 'names' => ['Anna']]])
->and(recPlaces($categories['new_customer_abos']))->toBe([
['place' => 1, 'value' => 2, 'names' => ['Ben']],
['place' => 2, 'value' => 1, 'names' => ['Anna']],
])
->and(recPlaces($categories['customer_abos_total']))->toBe([
['place' => 1, 'value' => 3, 'names' => ['Ben']],
['place' => 2, 'value' => 1, 'names' => ['Anna']],
])
->and(recPlaces($categories['customer_abo_points']))->toBe([
['place' => 1, 'value' => 120.5, 'names' => ['Ben']],
['place' => 2, 'value' => 80.0, 'names' => ['Anna']],
])
->and(recPlaces($categories['total_points']))->toBe([
['place' => 1, 'value' => 980.0, 'names' => ['Anna']],
['place' => 2, 'value' => 120.5, 'names' => ['Ben']],
]);
});
it('fuehrt Firmenkonto und Grosskunden nie auf', function (): void {
$company = recMakeUser('Firma', 'GmbH');
$bigCustomer = recMakeUser('Ruth', 'Gross');
$anna = recMakeUser('Anna');
config(['mivita.recognition_excluded_user_ids' => [$company->id, $bigCustomer->id]]);
recAddVolume($company->id, 5000, 2);
recAddVolume($bigCustomer->id, 7000, 1);
recAddVolume($anna->id, 300, 1);
$service = app(RecognitionService::class);
expect(recPlaces($service->recognitions(9, 2026)['categories']['total_points']))
->toBe([['place' => 1, 'value' => 300.0, 'names' => ['Anna']]])
->and(array_column($service->liveRankings(10, 2026)['categories']['total_points'], 'user_id'))
->not->toContain($company->id, $bigCustomer->id);
});
it('teilt Gleichstaende einen Platz und vergibt die Plaetze lueckenlos', function (): void {
foreach (['Carla' => 500, 'Anna' => 400, 'Ben' => 400, 'Dora' => 300, 'Emil' => 200, 'Fritz' => 100] as $name => $points) {
recAddVolume(recMakeUser($name)->id, $points, 1);
}
$categories = app(RecognitionService::class)->recognitions(9, 2026)['categories'];
// 1.000-Punkte-Shop mit Platz 4 (wie in der Mail), danach ist Schluss.
expect(recPlaces($categories['total_points']))->toBe([
['place' => 1, 'value' => 500.0, 'names' => ['Carla']],
['place' => 2, 'value' => 400.0, 'names' => ['Anna', 'Ben']],
['place' => 3, 'value' => 300.0, 'names' => ['Dora']],
['place' => 4, 'value' => 200.0, 'names' => ['Emil']],
]);
});
it('platziert Recruiter erst ab zwei Neupartnern', function (): void {
$anna = recMakeUser('Anna');
$ben = recMakeUser('Ben');
recMakeUser('Neu1', 'Partner', $anna->id, '2026-09-10');
recMakeUser('Neu2', 'Partner', $anna->id, '2026-09-11');
recMakeUser('Neu3', 'Partner', $ben->id, '2026-09-12');
$service = app(RecognitionService::class);
expect(recPlaces($service->recognitions(9, 2026)['categories']['recruiters']))
->toBe([['place' => 1, 'value' => 2, 'names' => ['Anna']]]);
});
it('zeigt Live-Ranglisten mit Wettkampf-Rang und hoechstens zehn Zeilen', function (): void {
foreach (range(1, 12) as $index) {
recAddVolume(recMakeUser('User'.str_pad((string) $index, 2, '0', STR_PAD_LEFT))->id, $index === 2 ? 1200 : 100 * $index, 1, 10);
}
$rows = app(RecognitionService::class)->liveRankings(10, 2026)['categories']['total_points'];
expect($rows)->toHaveCount(10)
->and(array_column(array_slice($rows, 0, 3), 'rank'))->toBe([1, 1, 3])
->and(array_column(array_slice($rows, 0, 3), 'first_name'))->toBe(['User02', 'User12', 'User11'])
->and($rows[0])->toHaveKey('profile_image_url');
});
it('liefert vor dem Stichtag den vorlaeufigen Stand und danach den eingefrorenen', function (): void {
$anna = recMakeUser('Anna');
recAddVolume($anna->id, 300, 1);
Carbon::setTestNow(Carbon::parse('2026-10-02 12:00:00'));
$service = app(RecognitionService::class);
$preliminary = $service->recognitions(9, 2026);
expect($preliminary['is_final'])->toBeFalse()
->and($preliminary['freeze_date']->toDateString())->toBe('2026-10-03');
Carbon::setTestNow(Carbon::parse('2026-10-03 06:15:00'));
$this->artisan('recognitions:freeze', ['--month' => 9, '--year' => 2026])->assertSuccessful();
// Spaetere Buchungen veraendern den festgeschriebenen Monat nicht mehr.
recAddVolume(recMakeUser('Spaet')->id, 9999, 1);
Cache::flush();
$final = $service->recognitions(9, 2026);
expect($final['is_final'])->toBeTrue()
->and($final['frozen_at']->toDateString())->toBe('2026-10-03')
->and(recPlaces($final['categories']['total_points']))->toEqual([['place' => 1, 'value' => 300, 'names' => ['Anna']]]);
});
it('friert per Command nur festschreibbare Monate ein und ueberschreibt nichts ohne --force', function (): void {
$anna = recMakeUser('Anna');
recAddVolume($anna->id, 300, 1);
Carbon::setTestNow(Carbon::parse('2026-10-02 06:15:00'));
$this->artisan('recognitions:freeze')->assertSuccessful();
expect(RecognitionSnapshot::query()->where('year', 2026)->where('month', 9)->exists())->toBeFalse()
->and(RecognitionSnapshot::query()->where('year', 2026)->where('month', 8)->exists())->toBeTrue()
->and(RecognitionSnapshot::query()->count())->toBe(8);
Carbon::setTestNow(Carbon::parse('2026-10-03 06:15:00'));
$this->artisan('recognitions:freeze')->assertSuccessful();
$frozenAt = RecognitionSnapshot::query()->where('month', 9)->value('frozen_at');
Carbon::setTestNow(Carbon::parse('2026-10-04 06:15:00'));
$this->artisan('recognitions:freeze')->assertSuccessful();
expect(RecognitionSnapshot::query()->count())->toBe(9)
->and(RecognitionSnapshot::query()->where('month', 9)->value('frozen_at'))->toEqual($frozenAt);
$this->artisan('recognitions:freeze', ['--month' => 9, '--year' => 2026, '--force' => true])->assertSuccessful();
expect(RecognitionSnapshot::query()->where('month', 9)->first()->frozen_at->toDateString())->toBe('2026-10-04');
});