Carbon::setTestNow()); function rtUser(): User { return User::forceCreate([ 'email' => 'rt-'.uniqid('', true).'@example.test', 'password' => bcrypt('secret'), 'lang' => 'de', 'm_level' => 1, 'active_date' => '2026-01-01', 'payment_account' => '2027-01-01', ]); } /** * Buchung mit explizit gesetztem Laufstand — so, wie sie eine Transaktion * hinterlassen hat, die die Nachbarzeile nicht sah. */ function rtRow(User $user, float $points, float $runningShop, float $runningTp = 0, int $status = 2, int $statusPoints = 1, int $month = 9): UserSalesVolume { return UserSalesVolume::forceCreate([ 'user_id' => $user->id, 'month' => $month, 'year' => 2026, 'date' => sprintf('2026-%02d-10', $month), 'points' => $points, 'total_net' => $points, 'status' => $status, 'status_points' => $statusPoints, 'month_KP_points' => $runningTp, 'month_TP_points' => $runningTp, 'month_shop_points' => $runningShop, ]); } it('erkennt einen ueberholten Laufstand und rechnet ihn neu', function () { $partner = rtUser(); $healthy = rtUser(); // Fall aus dem Messlauf (User 848, 10.09.): 129 → 506, dann 51 → 428 statt 557. rtRow($partner, 377, 377); rtRow($partner, 129, 506); rtRow($partner, 51, 428); rtRow($healthy, 100, 100); rtRow($healthy, 20, 120); $service = app(SalesVolumeRunningTotalService::class); $drift = $service->detectDrift(9, 2026); expect($drift)->toHaveKey($partner->id) ->and($drift)->not->toHaveKey($healthy->id) ->and($drift[$partner->id])->toBe(['rows' => 557.0, 'running' => 428.0]); $repaired = $service->repair(9, 2026); expect(array_keys($repaired))->toBe([$partner->id]); $rows = UserSalesVolume::where('user_id', $partner->id)->orderBy('id')->pluck('month_shop_points')->map(fn ($v) => (float) $v)->all(); expect($rows)->toBe([377.0, 506.0, 557.0]) ->and($service->detectDrift(9, 2026))->toBe([]); }); it('meldet keine Drift fuer reine Karrierepunkte und andere Monate', function () { $partner = rtUser(); // KP-only (Neupartner-Einschreibung) zaehlt nicht zu TP + Shop: Laufstand bleibt 0 + 80. rtRow($partner, 80, 80); rtRow($partner, 50, 80, 0, 5, 2); // Vormonat mit eigenem Laufstand — darf die September-Pruefung nicht beeinflussen. rtRow($partner, 999, 999, 0, 2, 1, 8); $service = app(SalesVolumeRunningTotalService::class); expect($service->detectDrift(9, 2026))->toBe([]) ->and($service->detectDrift(8, 2026))->toBe([]); }); it('prueft im Nachtlauf den laufenden Monat und den Vormonat nur solange er offen ist', function () { $service = app(SalesVolumeRunningTotalService::class); expect($service->monthsToGuard())->toBe([ ['month' => 8, 'year' => 2026], ['month' => 9, 'year' => 2026], ]); UserBusinessStructure::create(['month' => 8, 'year' => 2026, 'completed' => true, 'status' => 1]); expect($service->monthsToGuard())->toBe([['month' => 9, 'year' => 2026]]); Carbon::setTestNow('2026-10-01 02:45:00'); expect($service->monthsToGuard())->toBe([ ['month' => 9, 'year' => 2026], ['month' => 10, 'year' => 2026], ]); }); it('repariert per Kommando, im Dry-Run nur Anzeige', function () { $partner = rtUser(); rtRow($partner, 129, 129); rtRow($partner, 51, 51); $this->artisan('sales:repair-running-totals', ['--dry-run' => true]) ->expectsOutputToContain('Laufstaende 09/2026: 1 User mit Drift (DRY-RUN, nichts geschrieben)') ->expectsOutputToContain('Laufstaende 08/2026: keine Abweichung.') ->assertSuccessful(); expect((float) UserSalesVolume::where('user_id', $partner->id)->orderByDesc('id')->value('month_shop_points'))->toBe(51.0); $this->artisan('sales:repair-running-totals') ->expectsOutputToContain('Laufstaende 09/2026: 1 User mit Drift, neu berechnet') ->assertSuccessful(); expect((float) UserSalesVolume::where('user_id', $partner->id)->orderByDesc('id')->value('month_shop_points'))->toBe(180.0); $this->artisan('sales:repair-running-totals') ->expectsOutputToContain('Laufstaende 09/2026: keine Abweichung.') ->assertSuccessful(); }); it('rechnet einen abgerechneten Monat nur mit --force neu', function () { $partner = rtUser(); rtRow($partner, 129, 129, 0, 2, 1, 8); rtRow($partner, 51, 51, 0, 2, 1, 8); UserBusinessStructure::create(['month' => 8, 'year' => 2026, 'completed' => true, 'status' => 1]); $this->artisan('sales:repair-running-totals', ['month' => 8, 'year' => 2026]) ->expectsOutputToContain('Monat 08/2026 ist abgerechnet') ->assertFailed(); expect((float) UserSalesVolume::where('user_id', $partner->id)->orderByDesc('id')->value('month_shop_points'))->toBe(51.0); $this->artisan('sales:repair-running-totals', ['month' => 8, 'year' => 2026, '--dry-run' => true]) ->expectsOutputToContain('1 User mit Drift (DRY-RUN') ->assertSuccessful(); $this->artisan('sales:repair-running-totals', ['month' => 8, 'year' => 2026, '--force' => true]) ->expectsOutputToContain('neu berechnet') ->assertSuccessful(); expect((float) UserSalesVolume::where('user_id', $partner->id)->orderByDesc('id')->value('month_shop_points'))->toBe(180.0); }); it('liest die Zeilen fuer die Neuberechnung sperrend', function () { $query = SalesPointsVolume::runningTotalRows(1, 9, 2026); expect($query->getQuery()->lock)->toBeTrue(); }); it('erkennt Deadlocks und Lock-Timeouts als Concurrency-Fehler', function () { $deadlock = new QueryException('mysql', 'update x', [], new PDOException('SQLSTATE[40001]: Serialization failure: 1213 Deadlock found when trying to get lock')); $timeout = new QueryException('mysql', 'update x', [], new PDOException('SQLSTATE[HY000]: General error: 1205 Lock wait timeout exceeded; try restarting transaction')); $other = new QueryException('mysql', 'insert x', [], new PDOException('SQLSTATE[23000]: Integrity constraint violation')); expect(ConcurrencyError::isCausedBy($deadlock))->toBeTrue() ->and(ConcurrencyError::isCausedBy($timeout))->toBeTrue() ->and(ConcurrencyError::isCausedBy($other))->toBeFalse() ->and(ConcurrencyError::isCausedBy(new RuntimeException('Rechnung kaputt')))->toBeFalse(); });