mivita/tests/Feature/StatisticsConsistencyTest.php
Kevin 96545364f1 Team-Statistik = Team-Struktur (Provisionsbaum, Umschalter) und Laufstand-Fix in user_sales_volumes
K11 aus dem September-Protokoll (Tickets C1 und C2 im Entwicklungsplan), live seit 14.09.2026.

C1 – Statistik rechnet wie die Provision
- Standard-Baum ist der Provisionsbaum (Mitglieder wie TreeCalcBot: Mitgliedsebene,
  Abrechnungskonto, active_date; ausgefilterte Knoten kappen ihren Unterbaum, max. 21 Linien).
  Gesamte Downline bleibt per Umschalter erreichbar, Wahl in der Session.
- Punkte-Definition: Provisionspunkte (TP + Shop), reine Karrierepunkte ausgewiesen statt
  eingerechnet, negative Monatssalden je User wie in der Provision auf 0 gekappt.
- Abgeschlossene Monate nehmen die Mitglieder je Linie aus dem gespeicherten Provisionslauf
  (user_businesses), Snapshots je Baum-Definition (Payload-Version 3, Migration).
- Struktur-Modal: Gesamt aus den Linien-Punkten mit Dezimalen statt der int-Spalte total_pp.
- Messlauf statistics:compare-trees (Downline / Provisionsbaum / Struktur je VIP, Laufstand-Drift).

C2 – Laufstand-Race (Provision zu niedrig bei zeitgleichen Buchungen)
- Neuberechnung liest die Zeilen des Beraters sperrend (FOR UPDATE), Index (user_id, year, month).
- Paid-Pfad der Payone- und Stripe-Webhooks als DB::transaction mit 3 Anlaeufen; Deadlock und
  Lock-Timeout aus der Rechnungserstellung werden nicht mehr geschluckt.
- Sicherheitsnetz sales:repair-running-totals taeglich 02:45 vor dem Provisionslauf;
  abgeschlossene Monate werden nicht angefasst (Entscheid 14.09.: keine Nachzahlung fuer 06/08).

Doku: Entwicklungsplan zum Protokoll, Deploy-Notiz 14.09. (inkl. Migrations-Stolperstein:
Migrationen tragen Werte als Literale, nicht als Konstanten aus dem Anwendungscode).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:31:32 +00:00

250 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',
'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);
});