mivita/tests/Feature/StatisticsConsistencyTest.php
Kevin b7e8bbd951 Statistik: Neupartner zaehlen im Monat der Zahlung, nicht der Registrierung
Wer Ende August registriert und am 1. September bezahlt hat, tauchte in keiner
Neupartner-Zaehlung auf: Der August-Snapshot wurde um 04:45 eingefroren, da
fehlte noch das Abrechnungskonto; im September lag active_date im falschen
Monat. Betroffen sind 2026 acht Partner, auch im Recruiting ihrer Sponsoren.

Kundenentscheidung 16.09.2026: der Zeitpunkt der Zahlung ist massgeblich.

Neue Spalte users.partner_paid_at, gesetzt bei der ersten Kontozahlung
(Payment::paymentStatusPaidAction) und bei manueller Freischaltung durch den
Admin — nur, solange sie leer ist; Verlaengerung und Wiederherstellung aendern
sie nicht. Bestehende Partner werden per Migration aus der ersten bezahlten
Registrierungsbestellung rueckgefuellt (Zeitstempel der Punktebuchung, ersatzweise
Bestelldatum, ohne Bestellung das Aktivierungsdatum). Bewusst nicht
payment_account minus ein Jahr: das ergaebe bei Verlaengerern den Jahrestag der
Erstzahlung.

User::scopeNewPartnerIn() und isNewPartnerIn() lesen jetzt partner_paid_at —
damit Kachel, Recruiter-Ranking, Top 3 und Neupartner-Liste gleichermassen.
Die Liste und der CSV-Export zeigen "Bezahlt am" neben "Registriert am".

Unveraendert: die Teamstruktur haengt weiter an active_date (auch im
Provisionsbaum). Wer ist neu dazugekommen und wer gehoert zur Linie sind zwei
verschiedene Fragen.

Die eingefrorenen Monate Januar bis August enthalten sieben dieser Partner
nicht; ob sie per --force neu gebaut werden, ist eine Entscheidung — siehe
dev/2026-07-22/neupartner-zahlungsmonat.md.

Mit drin: die handgebaute SQLite-Tabelle in BackofficeDashboardServiceTest
kannte status_reason aus d6efe56 noch nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 14:48:01 +00:00

251 lines
9 KiB
PHP

<?php
use App\Http\Controllers\User\AboController;
use App\Http\Controllers\User\BackofficeStatisticsController;
use App\Models\UserAbo;
use App\Models\UserAboOrder;
use App\Models\UserSalesVolume;
use App\Services\Backoffice\BackofficeDashboardService;
use App\Services\Backoffice\BackofficeDrilldownService;
use App\Services\Backoffice\PersonalStatisticsService;
use App\Services\Backoffice\SalesVolumePoints;
use App\Services\Team\AnnualFeeDueService;
use App\User;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function scUser(?int $sponsor = null): User
{
return User::forceCreate([
'email' => 'sc-'.uniqid('', true).'@example.test',
'password' => bcrypt('secret'),
'lang' => 'de',
'm_level' => 1,
'm_sponsor' => $sponsor,
'active_date' => '2026-01-01',
'partner_paid_at' => '2026-01-01',
'payment_account' => '2027-01-01',
]);
}
function scVolume(User $user, float $points, int $status = 1, array $extra = []): UserSalesVolume
{
return UserSalesVolume::forceCreate(array_merge([
'user_id' => $user->id,
'month' => 9,
'year' => 2026,
'date' => '2026-09-05',
'points' => $points,
'total_net' => $points,
'status' => $status,
'status_points' => 1,
'status_turnover' => 1,
], $extra));
}
/**
* Ruft eine private Methode des Controllers auf — die Exportzuordnung ist bewusst
* nicht oeffentlich, muss aber gegen die Ueberschriften pruefbar bleiben.
*/
function scCsv(string $method, mixed ...$args): array
{
$controller = app(BackofficeStatisticsController::class);
return (new ReflectionMethod($controller, $method))->invoke($controller, ...$args);
}
// ---------------------------------------------------------------- A09
it('haelt in jedem CSV-Export Kopfzeile und Summenzeile gleich breit', function (string $metric) {
$headers = scCsv('csvHeaders', $metric);
$summary = scCsv('csvSummaryRow', [
'metric' => $metric,
'summary' => [
'count' => 1,
'customer_points' => 100,
'total_points' => 300,
'own_points' => 10,
'external_points' => 20,
'customer_abo_points' => 30,
'customer_single_order_points' => 40,
'customer_other_points' => 50,
'abo_count' => 2,
'points' => 60,
'deliveries' => 3,
],
]);
expect($summary)->toHaveCount(count($headers));
})->with(['new_partners', 'team_customer_abos', 'team_partner_abos', 'total_points', 'active_partners']);
it('stellt die Neupartner-Summen unter die richtigen Ueberschriften', function () {
$headers = scCsv('csvHeaders', 'new_partners');
$summary = scCsv('csvSummaryRow', [
'metric' => 'new_partners',
'summary' => ['count' => 1, 'customer_points' => 100, 'total_points' => 300],
]);
$byHeader = array_combine($headers, $summary);
expect($byHeader['Punkte seiner Kunden'])->toBe(100)
->and($byHeader['Gesamt inkl. Eigenbestellung'])->toBe(300)
->and($byHeader['Sponsor'])->toBe('');
});
// ---------------------------------------------------------------- A02
it('zaehlt in der persoenlichen Statistik nur freigegebene Buchungen', function () {
$user = scUser();
scVolume($user, 100, 1);
scVolume($user, 50, 3); // pending
scVolume($user, 25, 0); // nicht zugeordnet
$personal = app(PersonalStatisticsService::class)->monthly($user, 9, 2026);
$canonical = SalesVolumePoints::totalPointsByUser([$user->id], 9, 2026)->get($user->id);
expect($personal['totals']['total_points'])->toBe(100.0)
->and($personal['totals']['total_points'])->toBe($canonical);
});
it('bucht einen Storno im persoenlichen Segment seiner Ursprungsbuchung', function () {
$user = scUser();
scVolume($user, 100, 1);
scVolume($user, -100, 6, ['storno_origin_status' => 1]);
$groups = app(PersonalStatisticsService::class)->monthly($user, 9, 2026)['groups'];
expect($groups['own']['points'])->toBe(0.0)
->and($groups['other']['points'])->toBe(0.0);
});
it('laesst Altstornos ohne Ursprungsart unter Sonstige', function () {
$user = scUser();
scVolume($user, 100, 1);
scVolume($user, -100, 6);
$groups = app(PersonalStatisticsService::class)->monthly($user, 9, 2026)['groups'];
expect($groups['own']['points'])->toBe(100.0)
->and($groups['other']['points'])->toBe(-100.0);
});
// ---------------------------------------------------------------- A03
it('zeigt negative Monatssalden im Drilldown und bleibt mit der Uebersicht gleich', function () {
$root = scUser();
$plus = scUser($root->id);
$minus = scUser($root->id);
scVolume($plus, 100, 1);
scVolume($minus, -40, 1);
$overview = app(BackofficeDashboardService::class)->overview($root, 9, 2026);
$details = app(BackofficeDrilldownService::class)->details($root, 0, 'total_points', 9, 2026);
// Knotenregel des Provisionsbaums (K11): ein negativer Monatssaldo zaehlt 0,
// die Kachel zeigt also 100 und weist die 40 gesondert als nicht abgezogen aus.
// Der Berater bleibt in der Liste (Eigenpunkte -40, Gesamt 0).
expect((float) $overview['totals']['total_points'])->toBe(100.0)
->and((float) $overview['storno_floor_points'])->toBe(40.0)
->and($details['summary']['total_points'])->toBe(100.0)
->and($details['summary']['storno_floor_points'])->toBe(40.0)
->and($details['rows'])->toHaveCount(2)
->and(collect($details['rows'])->firstWhere('user_id', $minus->id)['own_points'])->toBe(-40.0)
->and(collect($details['rows'])->firstWhere('user_id', $minus->id)['total_points'])->toBe(0.0);
});
it('behaelt die Schwelle der 1000-Punkte-Liste', function () {
$root = scUser();
$stark = scUser($root->id);
$schwach = scUser($root->id);
scVolume($stark, 1200, 1);
scVolume($schwach, -40, 1);
$details = app(BackofficeDrilldownService::class)->details($root, 0, 'shop_1000', 9, 2026);
expect($details['rows'])->toHaveCount(1)
->and($details['rows'][0]['user_id'])->toBe($stark->id);
});
// ---------------------------------------------------------------- A18
it('kennzeichnet Formelinhalte im Export als Text', function (string $value) {
$safe = (new ReflectionMethod(BackofficeStatisticsController::class, 'csvSafeRow'))
->invoke(null, [$value]);
expect($safe[0])->toBe("'".$value);
})->with(['=1+1', '+49 170 1234567', '-SUM(A1)', '@import']);
it('laesst unverfaengliche Werte im Export unveraendert', function () {
$safe = (new ReflectionMethod(BackofficeStatisticsController::class, 'csvSafeRow'))
->invoke(null, ['Anna Muster', 'anna@example.test', 1234.5, 0, '', null]);
expect($safe)->toBe(['Anna Muster', 'anna@example.test', 1234.5, 0, '', null]);
});
// ---------------------------------------------------------------- A10
it('laedt die Lieferungszahl der Kundenabo-Liste vor', function () {
$user = scUser();
for ($i = 0; $i < 5; $i++) {
$abo = UserAbo::create([
'user_id' => $user->id,
'member_id' => $user->id,
'shopping_user_id' => 0,
'email' => 'a-'.uniqid('', true).'@example.test',
'is_for' => 'ot',
'payone_userid' => 0,
'clearingtype' => 'cc',
'amount' => 5000,
'active' => true,
'status' => 2,
'abo_interval' => 5,
'start_date' => '2026-08-01',
'next_date' => '2026-10-01',
]);
UserAboOrder::create([
'user_abo_id' => $abo->id,
'shopping_order_id' => 1,
'status' => 2,
'paid' => true,
]);
}
$relations = (new ReflectionClass(AboController::class))->getConstant('CUSTOMER_LIST_RELATIONS');
$abos = UserAbo::with($relations)->where('member_id', $user->id)->where('is_for', 'ot')->get();
DB::enableQueryLog();
DB::flushQueryLog();
foreach ($abos as $abo) {
$abo->getCountPaidOrders();
}
$queries = count(DB::getQueryLog());
DB::disableQueryLog();
expect($abos)->toHaveCount(5)
->and($queries)->toBe(0);
});
// ---------------------------------------------------------------- A17
it('zaehlt am Monatsende nicht in die falsche Faelligkeitsgruppe', function () {
$service = app(AnnualFeeDueService::class);
$key = fn (string $due, string $now) => (new ReflectionMethod($service, 'groupKeyFor'))
->invoke($service, Carbon::parse($due), Carbon::parse($now));
// Vom 31.01. aus endet "innerhalb eines Monats" am 28.02., nicht am 03.03.
expect($key('2026-03-02', '2026-01-31'))->toBe(AnnualFeeDueService::GROUP_DUE_2_MONTHS)
->and($key('2026-02-28', '2026-01-31'))->toBe(AnnualFeeDueService::GROUP_DUE_1_MONTH)
->and($key('2026-03-31', '2026-01-31'))->toBe(AnnualFeeDueService::GROUP_DUE_2_MONTHS)
->and($key('2026-04-01', '2026-01-31'))->toBe(AnnualFeeDueService::GROUP_DUE_3_MONTHS);
});