Statistik: abgeschlossene Monate werden vollstaendig eingefroren

Am ersten des Folgemonats wird der Monat abgerechnet, die Provision berechnet
und Gutschriften werden ausgestellt. Deswegen fliessen auch Stornos nicht mehr
rueckwirkend ein. Genau dieser Stand muss eingefroren werden — und zwar
einschliesslich Teamstruktur und Detaillisten, nicht nur der Kennzahlen.

Bisher hat `overlayCurrentAboStock()` beim Lesen eines gespeicherten Monats die
gesamte Kundenabo-Topliste live neu gerechnet, also auch abgerechnete
Monatswerte. Und die Detaillisten hinter den Kacheln lasen Monatsbuchungen und
Teamzuordnung grundsaetzlich neu — nach einem Sponsorwechsel oder einer
Loeschung konnte derselbe historische Klick andere Ergebnisse liefern als die
Kachel darueber.

Das Overlay entfaellt; der Snapshot wird unveraendert zurueckgegeben. Die
Detaillisten lesen die eingefrorene Mitgliedermenge aus dem Snapshot
(`user_ids` lag dort bereits, wurde nur nie benutzt) und die eingefrorenen
Abo-Ids aus dem neuen Feld `abo_ids`. Payload-Version 2.

Grenze, die bleibt: Ein Rebuild vergangener Monate friert die Struktur des
AUSFUEHRUNGSTAGS ein, nicht die des damaligen Monatsendes — frueheres
Sponsoring wird nirgends historisiert. Der `--force`-Lauf ist deshalb nur
richtig, wenn man bewusst einen einheitlichen Stand herstellt. Das ist hier der
Fall (Schemawechsel des Payloads), fuer den laufenden Betrieb genuegt der
naechtliche Lauf ohne `--force`.

Loest die August-Entscheidung F7 ("Abo-Bestaende tagesaktuell") ab. Zwei Tests
hielten die alte Regel fest und beschreiben jetzt die neue.

Mit drin, weil dieselben Dateien betroffen sind:
- Negative Monatssalden verschwanden aus dem Drilldown (`HAVING > 0`), obwohl
  ihre Betraege in der Uebersicht steckten. Nur die 1000-Punkte-Liste behaelt
  ihren Schwellenfilter (A03).
- Zusatzpunkte einer stornierten Bestellung werden anteilig verrechnet statt
  voll abgezogen (A06).
- Unformatierte Sortierwerte fuer die Detailliste (A14, siehe Folgecommit).

Audit-Befunde A03, A04, A06, A12.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-09-10 11:19:06 +00:00
parent 42ec51cb36
commit 8d7a98c685
9 changed files with 1101 additions and 158 deletions

View file

@ -0,0 +1,162 @@
<?php
use App\Models\UserAbo;
use App\Models\UserSalesVolume;
use App\Services\Backoffice\BackofficeDashboardService;
use App\Services\Backoffice\BackofficeDrilldownService;
use App\User;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
// September laeuft, August ist abgeschlossen.
Carbon::setTestNow('2026-09-15 10:00:00');
});
afterEach(fn () => Carbon::setTestNow());
function freezeUser(?int $sponsor = null): User
{
return User::forceCreate([
'email' => 'freeze-'.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 freezeVolume(User $user, float $points, int $month = 8): UserSalesVolume
{
return UserSalesVolume::forceCreate([
'user_id' => $user->id,
'month' => $month,
'year' => 2026,
'date' => '2026-0'.$month.'-05',
'points' => $points,
'total_net' => $points,
'status' => 1,
'status_points' => 1,
'status_turnover' => 1,
]);
}
function freezeAbo(User $member, string $isFor = 'ot', string $startDate = '2026-08-05'): UserAbo
{
return UserAbo::create([
'user_id' => $member->id,
'member_id' => $member->id,
'shopping_user_id' => 0,
'email' => 'a-'.uniqid('', true).'@example.test',
'is_for' => $isFor,
'payone_userid' => 0,
'clearingtype' => 'cc',
'amount' => 5000,
'active' => true,
'status' => 2,
'abo_interval' => 5,
'start_date' => $startDate,
'next_date' => '2026-10-05',
]);
}
it('haelt Punkte eines abgeschlossenen Monats nach einer spaeteren Korrektur fest', function () {
$root = freezeUser();
$anna = freezeUser($root->id);
$volume = freezeVolume($anna, 100);
app(BackofficeDashboardService::class)->storeSnapshot($root, 8, 2026);
$volume->update(['points' => 500]);
$overview = app(BackofficeDashboardService::class)->overview($root, 8, 2026);
expect($overview['_meta']['source'])->toBe('snapshot')
->and((float) $overview['totals']['total_points'])->toBe(100.0);
});
it('haelt auch die Abo-Bestaende und Ranglisten fest', function () {
$root = freezeUser();
$anna = freezeUser($root->id);
$abo = freezeAbo($anna);
app(BackofficeDashboardService::class)->storeSnapshot($root, 8, 2026);
// Kuendigung im September darf den August nicht mehr veraendern.
$abo->update(['active' => false, 'status' => 4, 'cancel_date' => '2026-09-10']);
$overview = app(BackofficeDashboardService::class)->overview($root, 8, 2026);
expect((int) $overview['totals']['team_customer_abos'])->toBe(1);
});
it('laesst einen spaeter dazugekommenen Teampartner aus dem abgeschlossenen Monat heraus', function () {
$root = freezeUser();
$anna = freezeUser($root->id);
freezeVolume($anna, 100);
app(BackofficeDashboardService::class)->storeSnapshot($root, 8, 2026);
// Neuer Teampartner im September, mit einer Buchung, die im August liegt
// (z. B. nachtraeglich zugeordnet) — der abgeschlossene Monat bleibt unberuehrt.
$spaeter = freezeUser($root->id);
freezeVolume($spaeter, 999);
app(BackofficeDashboardService::class)->flushLineBucketsMemo($root->id);
$overview = app(BackofficeDashboardService::class)->overview($root, 8, 2026);
$details = app(BackofficeDrilldownService::class)->details($root, 0, 'total_points', 8, 2026);
expect((float) $overview['totals']['total_points'])->toBe(100.0)
->and($details['summary']['total_points'])->toBe(100.0)
->and($details['rows'])->toHaveCount(1)
->and($details['rows'][0]['user_id'])->toBe($anna->id);
});
it('zeigt in der Abo-Detailliste genau die eingefrorenen Abos', function () {
$root = freezeUser();
$anna = freezeUser($root->id);
$abo = freezeAbo($anna);
app(BackofficeDashboardService::class)->storeSnapshot($root, 8, 2026);
$abo->update(['active' => false, 'status' => 4, 'cancel_date' => '2026-09-10']);
$abo->delete();
$details = app(BackofficeDrilldownService::class)->details($root, 0, 'team_customer_abos', 8, 2026);
expect($details['summary']['abo_count'])->toBe(1);
});
it('haelt Kachel und verlinkte Liste der neuen Abos zusammen', function () {
$root = freezeUser();
$anna = freezeUser($root->id);
$abo = freezeAbo($anna, 'ot', '2026-08-20'); // neu im August
app(BackofficeDashboardService::class)->storeSnapshot($root, 8, 2026);
$abo->update(['active' => false, 'status' => 4, 'cancel_date' => '2026-09-08']);
$overview = app(BackofficeDashboardService::class)->overview($root, 8, 2026);
$details = app(BackofficeDrilldownService::class)->details($root, 0, 'team_customer_abos', 8, 2026, true);
expect((int) $overview['totals']['team_customer_abos_new'])->toBe(1)
->and($details['summary']['abo_count'])->toBe(1);
});
it('rechnet den laufenden Monat weiterhin live', function () {
$root = freezeUser();
$anna = freezeUser($root->id);
$volume = freezeVolume($anna, 100, 9);
$volume->update(['points' => 500]);
$overview = app(BackofficeDashboardService::class)->overview($root, 9, 2026);
expect($overview['_meta']['source'])->toBe('live')
->and((float) $overview['totals']['total_points'])->toBe(500.0);
});

View file

@ -2,6 +2,7 @@
use App\Models\Product;
use App\Models\ShoppingOrder;
use App\Models\ShoppingOrderItem;
use App\Models\UserAbo;
use App\Models\UserAboItem;
use App\Models\UserLevel;
@ -251,7 +252,11 @@ it('liefert Top-10-Kundenabo-Ranglisten nach Anzahl und Umsatz mit Soll und Ist'
->and($top['by_points'][1]['points_actual'])->toBe(0.0);
});
it('ueberlagert Snapshot-Monate mit tagesaktuellen Abo-Bestandszahlen', function (): void {
it('haelt die Abo-Bestaende eines abgeschlossenen Monats fest', function (): void {
// Kundenentscheidung 2026-09-10: Ein abgeschlossener Monat ist abgerechnet und
// wird nicht mehr aus heutigen Daten nachgerechnet. Vorher wurden die
// Bestandszahlen hier bewusst live ueberlagert ("Abo-Bestaende tagesaktuell");
// dieser Test hielt die alte Regel fest und beschreibt jetzt die neue.
$root = bslMakeUser();
$anna = bslMakeUser($root->id);
@ -267,15 +272,18 @@ it('ueberlagert Snapshot-Monate mit tagesaktuellen Abo-Bestandszahlen', function
$overview = $service->overview($root, 5, 2026);
expect($overview['_meta']['source'])->toBe('snapshot')
->and($overview['abo_stock_live'])->toBeTrue()
->and($overview['lines'][1]['team_partner_abos'])->toBe(0) // Bestand: live
->and($overview['lines'][1]['team_customer_abos'])->toBe(1) // Bestand: live
->and($overview['lines'][1]['team_partner_abos_new'])->toBe(1) // "+N neu" im Mai: eingefroren
->and($overview['totals']['team_partner_abos'])->toBe(0)
->and($overview['totals']['team_customer_abos'])->toBe(1);
->and($overview)->not->toHaveKey('abo_stock_live')
->and($overview['lines'][1]['team_partner_abos'])->toBe(1) // Stand Mai
->and($overview['lines'][1]['team_customer_abos'])->toBe(0) // Stand Mai
->and($overview['lines'][1]['team_partner_abos_new'])->toBe(1)
->and($overview['totals']['team_partner_abos'])->toBe(1)
->and($overview['totals']['team_customer_abos'])->toBe(0);
});
it('ergaenzt beim Snapshot-Overlay Linien, die erst nach dem Snapshot entstanden sind', function (): void {
it('nimmt spaeter entstandene Linien nicht mehr in einen abgeschlossenen Monat auf', function (): void {
// Genau der Fall, der die Entscheidung ausgeloest hat: Teampartner koennen
// dazukommen oder verschoben werden. Ein abgerechneter Monat darf sich dadurch
// nicht mehr aendern.
$root = bslMakeUser();
$anna = bslMakeUser($root->id);
@ -294,12 +302,9 @@ it('ergaenzt beim Snapshot-Overlay Linien, die erst nach dem Snapshot entstanden
$overview = $service->overview($root, 5, 2026);
expect($overview['_meta']['source'])->toBe('snapshot')
->and($overview['lines'])->toHaveKey(2)
->and($overview['lines'][2]['team_partner_abos'])->toBe(1)
->and($overview['lines'][2]['team_customer_abos'])->toBe(1)
->and($overview['lines'][2]['team_partner_abos_new'])->toBe(0) // Monatswert: im Snapshot-Monat gab es die Linie nicht
->and($overview['totals']['team_partner_abos'])->toBe(2)
->and($overview['totals']['team_customer_abos'])->toBe(1);
->and($overview['lines'])->not->toHaveKey(2)
->and($overview['totals']['team_partner_abos'])->toBe(1)
->and($overview['totals']['team_customer_abos'])->toBe(0);
});
it('nimmt den Statistik-Nutzer selbst in die Kundenabo-Ranglisten auf', function (): void {
@ -499,7 +504,8 @@ it('summiert das Recruiter-Volumen aus Eigen- und Kundenpunkten inklusive Ketten
'status' => 1,
]);
$ranking = collect(app(BackofficeDashboardService::class)->overview($root, 6, 2026)['recruiter_ranking'])->keyBy('user_id');
$overview = app(BackofficeDashboardService::class)->overview($root, 6, 2026);
$ranking = collect($overview['recruiter_ranking'])->keyBy('user_id');
// Root: Annas 500 (400 eigene + 100 Kunden) + rekursiv Bens 200
expect($ranking->get($root->id)['volume_points'])->toBe(700.0)
@ -507,6 +513,14 @@ it('summiert das Recruiter-Volumen aus Eigen- und Kundenpunkten inklusive Ketten
->and($ranking->get($root->id)['partners'][0]['children'][0]['total_points'])->toBe(200.0)
// Anna selbst: nur Bens Punkte
->and($ranking->get($anna->id)['volume_points'])->toBe(200.0);
// Summenzeile: jeder Neupartner genau EINMAL. Die Spaltensumme waere
// 700 + 200 = 900, weil Ben im Kettenvolumen von Root steckt und zusaetzlich
// in Annas Zeile. Richtig sind Annas 500 + Bens 200 = 700 bei 2 Neupartnern.
expect($overview['recruiter_totals'])->toBe([
'new_partner_count' => 2,
'volume_points' => 700.0,
]);
});
it('liefert im Neupartner-Drilldown zusaetzlich die Spalte Punkte gesamt', function (): void {
@ -536,3 +550,76 @@ it('liefert im Neupartner-Drilldown zusaetzlich die Spalte Punkte gesamt', funct
->and($details['summary']['customer_points'])->toBe(250.0)
->and($details['summary']['total_points'])->toBe(350.0);
});
it('teilt die abgerechneten Kundenabo-Punkte in wiederkehrende und einmalig dazubestellte auf', function (): void {
// Kundenwunsch 2026-09-07: "1231 abgerechnet - davon 1000 reine Abo-Punkte -
// 231 einmalige Bestellungen dazu."
$root = bslMakeUser();
$anna = bslMakeUser($root->id);
$order = ShoppingOrder::forceCreate([
'shopping_user_id' => 0,
'auth_user_id' => $anna->id,
'country_id' => 0,
'user_shop_id' => 0,
'is_abo' => true,
'paid' => true,
]);
// 2 x 500 wiederkehrend + 1 x 231 einmalig dazu = 1231
ShoppingOrderItem::forceCreate([
'shopping_order_id' => $order->id, 'product_id' => 1, 'qty' => 2, 'points' => 500, 'is_abo_addon' => false,
]);
ShoppingOrderItem::forceCreate([
'shopping_order_id' => $order->id, 'product_id' => 2, 'qty' => 1, 'points' => 231, 'is_abo_addon' => true,
]);
UserSalesVolume::forceCreate([
'user_id' => $anna->id,
'shopping_order_id' => $order->id,
'month' => 6,
'year' => 2026,
'points' => 1231,
'status' => 2,
]);
$row = collect(app(BackofficeDashboardService::class)->overview($root, 6, 2026)['top_customer_abos']['by_points'])
->firstWhere('user_id', $anna->id);
expect($row['points_actual'])->toBe(1231.0)
->and($row['points_recurring'])->toBe(1000.0)
->and($row['points_addon'])->toBe(231.0)
// Die beiden Spalten muessen die angezeigte Gesamtsumme ergeben.
->and($row['points_recurring'] + $row['points_addon'])->toBe($row['points_actual']);
});
it('zaehlt Abo-Bestellungen ohne Einmal-Positionen komplett als wiederkehrend', function (): void {
// Bestellungen vor 06/2026 kennen is_abo_addon nicht (Default false) —
// die Aufteilung darf dort nichts erfinden.
$root = bslMakeUser();
$anna = bslMakeUser($root->id);
$order = ShoppingOrder::forceCreate([
'shopping_user_id' => 0,
'auth_user_id' => $anna->id,
'country_id' => 0,
'user_shop_id' => 0,
'is_abo' => true,
'paid' => true,
]);
UserSalesVolume::forceCreate([
'user_id' => $anna->id,
'shopping_order_id' => $order->id,
'month' => 6,
'year' => 2026,
'points' => 400,
'status' => 2,
]);
$row = collect(app(BackofficeDashboardService::class)->overview($root, 6, 2026)['top_customer_abos']['by_points'])
->firstWhere('user_id', $anna->id);
expect($row['points_actual'])->toBe(400.0)
->and($row['points_recurring'])->toBe(400.0)
->and($row['points_addon'])->toBe(0.0);
});

View file

@ -0,0 +1,131 @@
<?php
use App\Models\ShoppingOrder;
use App\Models\ShoppingOrderItem;
use App\Models\UserSalesVolume;
use App\Services\Backoffice\BackofficeDashboardService;
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(fn () => Carbon::setTestNow('2026-09-10 12:00:00'));
afterEach(fn () => Carbon::setTestNow());
function addonUser(?int $sponsor = null): User
{
return User::forceCreate([
'email' => 'addon-'.uniqid().'@example.test',
'password' => bcrypt('secret'),
'lang' => 'de',
'admin' => 0,
'm_level' => 1,
'm_sponsor' => $sponsor,
'active_date' => '2026-01-01',
'payment_account' => '2027-01-01',
]);
}
/**
* Abo-Bestellung mit optionalem Einmal-Zusatz und der zugehoerigen Umsatzbuchung.
*/
function addonOrder(User $member, float $points, float $addonPoints = 0.0): ShoppingOrder
{
$order = ShoppingOrder::create([
'shopping_user_id' => 0,
'auth_user_id' => $member->id,
'member_id' => $member->id,
'country_id' => 1,
'language' => 'de',
'user_shop_id' => 1,
'payment_for' => 6,
'total' => $points,
'subtotal' => $points,
'points' => $points,
'paid' => true,
'shipped' => 0,
'is_abo' => true,
'mode' => 'test',
]);
if ($addonPoints > 0) {
ShoppingOrderItem::forceCreate([
'shopping_order_id' => $order->id,
'product_id' => 0,
'qty' => 1,
'price' => $addonPoints,
'points' => $addonPoints,
'is_abo_addon' => true,
]);
}
return $order;
}
function addonVolume(User $member, float $points, ShoppingOrder $order): UserSalesVolume
{
return UserSalesVolume::forceCreate([
'user_id' => $member->id,
'shopping_order_id' => $order->id,
'month' => 9,
'year' => 2026,
'date' => '2026-09-01',
'status' => 2,
'status_points' => 1,
'status_turnover' => null,
'points' => $points,
'total_net' => $points,
]);
}
function addonRankingRow(User $root): array
{
return app(BackofficeDashboardService::class)->overview($root, 9, 2026)['top_customer_abos']['by_points'][0];
}
it('weist die Zusatzpunkte einer stornierten Bestellung nicht mehr aus', function () {
$root = addonUser();
$member = addonUser($root->id);
$storniert = addonOrder($member, 100.0, 20.0);
SalesPointsVolume::cancelSalesPointsVolume(addonVolume($member, 100.0, $storniert), 9911);
addonVolume($member, 100.0, addonOrder($member, 100.0));
$row = addonRankingRow($root);
expect($row['points_actual'])->toBe(100.0)
->and($row['points_addon'])->toBe(0.0)
->and($row['points_recurring'])->toBe(100.0);
});
it('weist die Zusatzpunkte einer nicht stornierten Bestellung unveraendert aus', function () {
$root = addonUser();
$member = addonUser($root->id);
addonVolume($member, 100.0, addonOrder($member, 100.0, 20.0));
$row = addonRankingRow($root);
expect($row['points_actual'])->toBe(100.0)
->and($row['points_addon'])->toBe(20.0)
->and($row['points_recurring'])->toBe(80.0);
});
it('rechnet einen Teilstorno anteilig auf die Zusatzpunkte an', function () {
$root = addonUser();
$member = addonUser($root->id);
$order = addonOrder($member, 100.0, 20.0);
addonVolume($member, 100.0, $order);
addonVolume($member, -50.0, $order)->update(['status' => 6, 'status_turnover' => 2, 'storno_origin_status' => 2]);
$row = addonRankingRow($root);
expect($row['points_actual'])->toBe(50.0)
->and($row['points_addon'])->toBe(10.0)
->and($row['points_recurring'])->toBe(40.0);
});

View file

@ -0,0 +1,243 @@
<?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);
expect((float) $overview['totals']['total_points'])->toBe(60.0)
->and($details['summary']['total_points'])->toBe(60.0)
->and($details['rows'])->toHaveCount(2);
});
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);
});

View file

@ -22,6 +22,7 @@ beforeEach(function () {
Schema::dropIfExists('user_abos');
Schema::dropIfExists('payment_transactions');
Schema::dropIfExists('shopping_payments');
Schema::dropIfExists('shopping_order_items');
Schema::dropIfExists('shopping_orders');
Schema::dropIfExists('users');
Schema::dropIfExists('user_levels');
@ -104,6 +105,19 @@ beforeEach(function () {
$table->softDeletes();
});
// Positionsebene: nur fuer die Aufteilung der Abo-Punkte in wiederkehrende
// und einmalig dazubestellte (is_abo_addon) noetig.
Schema::create('shopping_order_items', function ($table) {
$table->increments('id');
$table->unsignedInteger('shopping_order_id');
$table->unsignedInteger('product_id')->nullable();
$table->unsignedInteger('qty')->default(1);
$table->decimal('points', 10, 2)->nullable();
$table->boolean('is_abo_addon')->default(false);
$table->timestamps();
$table->softDeletes();
});
Schema::create('shopping_payments', function ($table) {
$table->increments('id');
$table->unsignedInteger('shopping_order_id');
@ -134,6 +148,8 @@ beforeEach(function () {
$table->decimal('total_net', 13, 2)->nullable();
$table->unsignedTinyInteger('status')->nullable();
$table->unsignedTinyInteger('status_turnover')->nullable();
// Ursprungsart einer Stornobuchung — gebraucht von den Eigen-/Shop-Praedikaten.
$table->unsignedTinyInteger('storno_origin_status')->nullable();
// Nur-KP-Kennzeichen (Neupartner-Einschreibungspunkte) — gebraucht von
// SalesVolumePoints::nonPaylinePoints().
$table->unsignedTinyInteger('status_points')->nullable();