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:
parent
1900860c74
commit
92344f7080
9 changed files with 1101 additions and 158 deletions
243
tests/Feature/StatisticsConsistencyTest.php
Normal file
243
tests/Feature/StatisticsConsistencyTest.php
Normal 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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue