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>
186 lines
7 KiB
PHP
186 lines
7 KiB
PHP
<?php
|
|
|
|
use App\Models\UserBusinessStructure;
|
|
use App\Models\UserSalesVolume;
|
|
use App\Services\BusinessPlan\SalesPointsVolume;
|
|
use App\Services\BusinessPlan\SalesVolumeRunningTotalService;
|
|
use App\Services\Database\ConcurrencyError;
|
|
use App\User;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Database\QueryException;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* Ticket C2 (Entwicklungsplan September 2026): Laufstand-Drift in
|
|
* `user_sales_volumes` — Erkennung, Reparatur, Sicherheitsnetz-Kommando und
|
|
* die sperrende Lesart der Neuberechnung.
|
|
*/
|
|
uses(TestCase::class, RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
Carbon::setTestNow('2026-09-15 10:00:00');
|
|
});
|
|
|
|
afterEach(fn () => 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();
|
|
});
|