Statistik: Stornos mindern die richtige Punktekategorie

Ein Storno einer Shop-Bestellung wurde als Eigenpunkt gebucht und liess damit
die Shop-Provisionsbasis ungemindert. Betroffen sind 7 Faelle aus April bis
August 2026 (178,82 EUR Differenz in der Provisionsbasis, gegen user_businesses
geprueft).

Ursache: `status_turnover` beschreibt nur, wohin der UMSATZ gehoert. Die Punkte
folgen einer anderen Regel — eine Gutschrift (Status 4) bucht ihre Punkte immer
auf KP/TP, auch wenn ihr Umsatz dem Shop zugeordnet ist. Die Gegenbuchung kann
diese Aufteilung ohne die Ursprungsart nicht spiegeln, deshalb haelt
`storno_origin_status` sie jetzt auf der Stornozeile fest. NULL heisst
"Altzeile ohne Angabe" und faellt auf das bisherige Verhalten zurueck —
abgeschlossene Monate aendern sich dadurch nicht.

Stornoregel (Kundenentscheidung): Ein Storno mindert nur den Monat der
Ursprungsbestellung. Ist der Monat abgeschlossen, passiert nichts mehr, denn
Abrechnung und Gutschriften stehen. Ein Minus in den neuen Monat mitzunehmen
waere nicht nachvollziehbar. Fuer den Ausnahmefall gibt es den Settings-Schalter
`storno-points-current-month`, standardmaessig aus.

Ausserdem: Die persoenliche Monatsstatistik summierte Status 0 (nicht
zugeordnet) und 3 (pending) mit, die gemeinsame Punkteauswertung nicht. Beide
nutzen jetzt dieselbe Statusliste, und ein Storno mindert das Segment seiner
Ursprungsbuchung statt pauschal in "Sonstige" zu laufen.

Die Migration fuer `status_turnover` zieht nur das Schema auf den tatsaechlichen
Stand nach (nullable statt NOT NULL) — auf Produktion faktisch wirkungslos,
aber Neuinstallationen und die Testdatenbank haben damit dieselbe Semantik.

Audit-Befunde A02, A06, A15, R01-R03.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-09-10 11:18:40 +00:00
parent 384b2dcff1
commit 42ec51cb36
10 changed files with 733 additions and 24 deletions

View file

@ -0,0 +1,124 @@
<?php
use App\Models\ShoppingOrder;
use App\Models\UserSalesVolume;
use App\Services\Backoffice\PersonalStatisticsService;
use App\User;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\URL;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
Carbon::setTestNow(Carbon::parse('2026-09-08'));
URL::resolveMissingNamedRoutesUsing(fn (string $name, $parameters = []) => '/'.$name);
});
afterEach(function () {
Carbon::setTestNow();
});
function pahUser(string $email): User
{
return User::forceCreate([
'email' => $email,
'password' => bcrypt('secret'),
'lang' => 'de',
'm_level' => 1,
'admin' => 1,
'active_date' => '2026-01-10',
'payment_account' => now()->addYear(),
]);
}
function pahVolume(User $user, bool $isAbo, int $status = 1): UserSalesVolume
{
$order = ShoppingOrder::create([
'shopping_user_id' => 0,
'auth_user_id' => $user->id,
'member_id' => $user->id,
'country_id' => 1,
'language' => 'de',
'user_shop_id' => 1,
'payment_for' => 6,
'total' => 50.00,
'subtotal' => 50.00,
'points' => 45,
'paid' => true,
'shipped' => 0,
'is_abo' => $isAbo,
'mode' => 'live',
]);
return UserSalesVolume::forceCreate([
'user_id' => $user->id,
'shopping_order_id' => $order->id,
'month' => 9,
'year' => 2026,
'date' => '08.09.2026',
'status' => $status,
'points' => 45,
'total_net' => 100,
]);
}
it('hebt Abo-Bestellungen in der Dashboard-Karte "Aktuelle Punkte" ab', function (): void {
// Kundenrueckmeldung 08.09.2026: In "Aktuelle Punkte" waren die Abos nicht
// hervorgehoben — die Kennzeichnung gab es bisher nur unter Mein Team > Punkte.
$user = pahUser('pah-dashboard@example.com');
pahVolume($user, isAbo: true);
pahVolume($user, isAbo: false);
$html = view('dashboard._points', ['user' => $user->fresh()])->render();
expect($html)
->toContain('btn-xs btn-info')
->toContain('btn-xs btn-primary')
->toContain(e(__('abo.abo_order_short')))
->toContain(e(__('abo.points_order_legend')))
// Kundenwunsch 09.09.2026: auch in der Spalte "Art" haengt der Abo-Zusatz dahinter
->toContain(e(__('payment.advisor_order').' '.__('abo.abo_order_short')));
});
it('liefert das Abo-Kennzeichen fuer die persoenliche Statistik mit', function (): void {
$user = pahUser('pah-personal@example.com');
pahVolume($user, isAbo: true);
pahVolume($user, isAbo: false);
$groups = app(PersonalStatisticsService::class)->monthly($user, 9, 2026)['groups'];
$rows = collect($groups)->flatMap(fn (array $group) => $group['rows']);
expect($rows->pluck('is_abo')->all())->toContain(true)->toContain(false);
expect($rows->firstWhere('is_abo', true)['status_label'])
->toContain(__('abo.abo_order_short'));
expect($rows->firstWhere('is_abo', false)['status_label'])
->not->toContain(__('abo.abo_order_short'));
});
it('haengt den Abo-Zusatz nur bei Abo-Lieferungen an die Art', function (): void {
$user = pahUser('pah-model@example.com');
$abo = pahVolume($user, isAbo: true)->fresh(['shopping_order']);
$shop = pahVolume($user, isAbo: false)->fresh(['shopping_order']);
expect($abo->isAboOrder())->toBeTrue();
expect($shop->isAboOrder())->toBeFalse();
expect($abo->getStatusTypeWithAbo())->toBe($abo->getStatusType().' '.__('abo.abo_order_short'));
expect($shop->getStatusTypeWithAbo())->toBe($shop->getStatusType());
});
it('faerbt Abo-Bestellungen auch in "Meine Statistik" ein', function (): void {
// Die Seite haengt an layouts.layout-2 und laesst sich nicht isoliert rendern —
// geprueft wird deshalb die Verdrahtung von is_abo in der Blade-Quelle.
$view = file_get_contents(resource_path('views/user/backoffice/statistics/my.blade.php'));
expect($view)
->toContain("\$row['is_abo']")
->toContain("'info' : 'primary'")
->toContain("'info' : 'secondary'")
->toContain("__('abo.points_order_legend')");
});

View file

@ -0,0 +1,122 @@
<?php
use App\Models\Setting;
use App\Models\UserSalesVolume;
use App\Services\BusinessPlan\SalesPointsVolume;
use App\User;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
Carbon::setTestNow('2026-09-09 12:00:00');
});
afterEach(function () {
Carbon::setTestNow();
});
function stornoUser(): User
{
return User::forceCreate([
'email' => 'storno-'.uniqid().'@example.test',
'password' => bcrypt('secret'),
'lang' => 'de',
'admin' => 0,
'm_level' => 1,
]);
}
function stornoVolume(User $user, int $month, int $year, float $points = 100.0): UserSalesVolume
{
return UserSalesVolume::forceCreate([
'user_id' => $user->id,
'user_invoice_id' => 4711,
'month' => $month,
'year' => $year,
'date' => sprintf('%d-%02d-05', $year, $month),
'points' => $points,
'total_net' => $points,
'status' => 2,
'status_points' => 1,
'status_turnover' => 1,
]);
}
it('bucht den Storno, solange der Monat der Ursprungsbuchung noch laeuft', function () {
$user = stornoUser();
$original = stornoVolume($user, 9, 2026);
$cancellation = SalesPointsVolume::cancelSalesPointsVolume($original, 9911);
expect($cancellation)->not->toBeNull()
->and((float) $cancellation->points)->toBe(-100.0)
->and((int) $cancellation->status)->toBe(6)
->and((int) $cancellation->month)->toBe(9)
->and((int) $cancellation->year)->toBe(2026);
expect(UserSalesVolume::where('user_id', $user->id)->where('status', 6)->count())->toBe(1);
});
it('laesst einen abgeschlossenen Monat unveraendert', function () {
$user = stornoUser();
$original = stornoVolume($user, 8, 2026);
$cancellation = SalesPointsVolume::cancelSalesPointsVolume($original, 9911);
expect($cancellation)->toBeNull()
->and(UserSalesVolume::where('user_id', $user->id)->where('status', 6)->count())->toBe(0);
});
it('behandelt denselben Monat im Vorjahr als abgeschlossen', function () {
$user = stornoUser();
$original = stornoVolume($user, 9, 2025);
expect(SalesPointsVolume::isStornoMonthOpen($original))->toBeFalse()
->and(SalesPointsVolume::cancelSalesPointsVolume($original, 9911))->toBeNull();
});
it('zieht abgeschlossene Monate im laufenden Monat ab, wenn der Schalter gesetzt ist', function () {
Setting::setContentBySlug(SalesPointsVolume::SETTING_STORNO_CURRENT_MONTH, 1, 'bool');
$user = stornoUser();
$original = stornoVolume($user, 8, 2026);
$cancellation = SalesPointsVolume::cancelSalesPointsVolume($original, 9911);
expect($cancellation)->not->toBeNull()
->and((float) $cancellation->points)->toBe(-100.0)
->and((int) $cancellation->month)->toBe(9)
->and((int) $cancellation->year)->toBe(2026);
});
it('ist ohne gesetzten Schalter ausgeschaltet', function () {
expect(SalesPointsVolume::stornoDeductsInCurrentMonth())->toBeFalse();
Setting::setContentBySlug(SalesPointsVolume::SETTING_STORNO_CURRENT_MONTH, 0, 'bool');
expect(SalesPointsVolume::stornoDeductsInCurrentMonth())->toBeFalse();
Setting::setContentBySlug(SalesPointsVolume::SETTING_STORNO_CURRENT_MONTH, 1, 'bool');
expect(SalesPointsVolume::stornoDeductsInCurrentMonth())->toBeTrue();
});
it('folgt der Anwendungsuhr, nicht der Systemuhr', function () {
// Bewusst ein anderer Kalendermonat als der des Testlaufs: die Regel darf
// nicht davon abhaengen, welchen Monat der Server gerade zeigt.
Carbon::setTestNow('2027-03-15 09:00:00');
$user = stornoUser();
$imLaufendenMonat = stornoVolume($user, 3, 2027);
$imVormonat = stornoVolume($user, 2, 2027);
expect(SalesPointsVolume::isStornoMonthOpen($imLaufendenMonat))->toBeTrue()
->and(SalesPointsVolume::isStornoMonthOpen($imVormonat))->toBeFalse();
$cancellation = SalesPointsVolume::cancelSalesPointsVolume($imLaufendenMonat, 9911);
expect((int) $cancellation->month)->toBe(3)
->and((int) $cancellation->year)->toBe(2027)
->and($cancellation->message)->toBe('Storniert am 15.03.2027');
});

View file

@ -0,0 +1,175 @@
<?php
use App\Models\UserSalesVolume;
use App\Services\Backoffice\SalesVolumePoints;
use App\Services\BusinessPlan\SalesPointsVolume;
use App\User;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
Carbon::setTestNow('2026-09-09 12:00:00');
});
afterEach(function () {
Carbon::setTestNow();
});
function stornoCatUser(): User
{
return User::forceCreate([
'email' => 'storno-cat-'.uniqid().'@example.test',
'password' => bcrypt('secret'),
'lang' => 'de',
'admin' => 0,
'm_level' => 1,
]);
}
/**
* Buchung wie auf dem regulaeren Bestellweg: `status_turnover` bleibt ungesetzt.
*/
function stornoCatVolume(User $user, int $status, float $points, ?int $statusTurnover = null): UserSalesVolume
{
return UserSalesVolume::forceCreate([
'user_id' => $user->id,
'user_invoice_id' => 4711,
'month' => 9,
'year' => 2026,
'date' => '2026-09-05',
'points' => $points,
'total_net' => $points,
'status' => $status,
'status_points' => 1,
'status_turnover' => $statusTurnover,
]);
}
it('kennzeichnet den Storno einer Shopbestellung als Shopgeschaeft', function () {
$user = stornoCatUser();
$original = stornoCatVolume($user, 2, 100.0);
$cancellation = SalesPointsVolume::cancelSalesPointsVolume($original, 9911);
expect((int) $cancellation->status_turnover)->toBe(2);
});
it('kennzeichnet den Storno einer Beraterbestellung als Eigengeschaeft', function () {
$user = stornoCatUser();
$original = stornoCatVolume($user, 1, 100.0);
$cancellation = SalesPointsVolume::cancelSalesPointsVolume($original, 9911);
expect((int) $cancellation->status_turnover)->toBe(1);
});
it('mindert beim Shop-Storno die Shop-Provisionsbasis statt der Eigenwerte', function () {
$user = stornoCatUser();
$original = stornoCatVolume($user, 2, 100.0);
SalesPointsVolume::cancelSalesPointsVolume($original, 9911);
$last = UserSalesVolume::where('user_id', $user->id)->orderByDesc('id')->first();
expect((float) $last->month_shop_total_net)->toBe(0.0)
->and((float) $last->month_shop_points)->toBe(0.0)
->and((float) $last->month_total_net)->toBe(0.0)
->and((float) $last->month_KP_points)->toBe(0.0);
});
it('mindert beim Eigen-Storno die Eigenwerte und laesst die Shop-Basis unberuehrt', function () {
$user = stornoCatUser();
stornoCatVolume($user, 2, 50.0);
$original = stornoCatVolume($user, 1, 100.0);
SalesPointsVolume::cancelSalesPointsVolume($original, 9911);
$last = UserSalesVolume::where('user_id', $user->id)->orderByDesc('id')->first();
expect((float) $last->month_KP_points)->toBe(0.0)
->and((float) $last->month_total_net)->toBe(0.0)
->and((float) $last->month_shop_total_net)->toBe(50.0)
->and((float) $last->month_shop_points)->toBe(50.0);
});
it('zaehlt jede Zeile genau einmal — Shop-Storno mindert die Kundenpunkte', function () {
$user = stornoCatUser();
stornoCatVolume($user, 2, 100.0);
stornoCatVolume($user, 6, -100.0, 2);
$rows = UserSalesVolume::query()
->where('user_id', $user->id)
->selectRaw(SalesVolumePoints::ownPoints().' as own')
->selectRaw(SalesVolumePoints::externalPoints().' as external')
->selectRaw(SalesVolumePoints::totalPoints().' as total')
->first();
expect((float) $rows->own)->toBe(0.0)
->and((float) $rows->external)->toBe(0.0)
->and((float) $rows->total)->toBe(0.0);
});
it('laesst Altzeilen ohne Herkunft bei den Eigenpunkten', function () {
$user = stornoCatUser();
stornoCatVolume($user, 2, 100.0);
stornoCatVolume($user, 6, -100.0);
$rows = UserSalesVolume::query()
->where('user_id', $user->id)
->selectRaw(SalesVolumePoints::ownPoints().' as own')
->selectRaw(SalesVolumePoints::externalPoints().' as external')
->selectRaw(SalesVolumePoints::totalPoints().' as total')
->first();
expect((float) $rows->own)->toBe(-100.0)
->and((float) $rows->external)->toBe(100.0)
->and((float) $rows->total)->toBe(0.0);
});
it('spiegelt den Storno einer Gutschrift mit Shop-Umsatz vollstaendig', function () {
$user = stornoCatUser();
$credit = stornoCatVolume($user, 4, 100.0, 2);
SalesPointsVolume::reCalculateSalesPointsVolume($user->id, 9, 2026);
$before = $credit->fresh();
expect((float) $before->month_KP_points)->toBe(100.0)
->and((float) $before->month_shop_total_net)->toBe(100.0)
->and((float) $before->month_shop_points)->toBe(0.0);
$cancellation = SalesPointsVolume::cancelSalesPointsVolume($credit, 9911)->fresh();
// Punkte gehen wie im Gutschrift-Zweig auf KP, der Umsatz auf den Shop.
expect((int) $cancellation->storno_origin_status)->toBe(4)
->and((float) $cancellation->month_KP_points)->toBe(0.0)
->and((float) $cancellation->month_shop_points)->toBe(0.0)
->and((float) $cancellation->month_shop_total_net)->toBe(0.0)
->and((float) $cancellation->month_total_net)->toBe(0.0);
});
it('haelt die Ursprungsart der Stornobuchung fest', function () {
$user = stornoCatUser();
expect((int) SalesPointsVolume::cancelSalesPointsVolume(stornoCatVolume($user, 2, 10.0), 1)->storno_origin_status)->toBe(2)
->and((int) SalesPointsVolume::cancelSalesPointsVolume(stornoCatVolume($user, 1, 10.0), 2)->storno_origin_status)->toBe(1)
->and((int) SalesPointsVolume::cancelSalesPointsVolume(stornoCatVolume($user, 5, 10.0), 3)->storno_origin_status)->toBe(5);
});
it('zaehlt den Storno einer Gutschrift bei den Eigenpunkten', function () {
$user = stornoCatUser();
$credit = stornoCatVolume($user, 4, 100.0, 2);
SalesPointsVolume::cancelSalesPointsVolume($credit, 9911);
$rows = UserSalesVolume::query()
->where('user_id', $user->id)
->selectRaw(SalesVolumePoints::ownPoints().' as own')
->selectRaw(SalesVolumePoints::externalPoints().' as external')
->selectRaw(SalesVolumePoints::totalPoints().' as total')
->first();
expect((float) $rows->own)->toBe(0.0)
->and((float) $rows->external)->toBe(0.0)
->and((float) $rows->total)->toBe(0.0);
});