mivita/app/Console/Commands/BackofficeStoreStatisticsSnapshots.php
Kevin 8d7a98c685 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>
2026-09-10 11:19:06 +00:00

140 lines
4.3 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Models\BackofficeStatisticsSnapshot;
use App\Services\Backoffice\BackofficeDashboardService;
use App\User;
use Carbon\Carbon;
use Illuminate\Console\Command;
class BackofficeStoreStatisticsSnapshots extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'backoffice:store-statistics-snapshots
{--user= : Nur einen bestimmten User berechnen (user_id)}
{--month= : Nur einen bestimmten Monat berechnen}
{--year= : Nur ein bestimmtes Jahr berechnen}
{--force : Bereits vorhandene Snapshots überschreiben}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Speichert Backoffice-Statistik-Snapshots fuer abgeschlossene Monate';
/**
* Der Snapshot IST der abgeschlossene Monat — siehe
* {@see BackofficeDashboardService} zur fachlichen Begruendung. Er wird am
* ersten Tag des Folgemonats gebaut, wenn Abrechnung und Gutschriften stehen.
*
* Wichtig bei `--force`: Ein neu gebauter Snapshot friert die Struktur des
* AUSFUEHRUNGSTAGS ein, nicht die des damaligen Monatsendes — frueheres
* Sponsoring wird nirgends historisiert. Ein Rebuild vergangener Monate ist
* deshalb nur dann richtig, wenn man bewusst einen einheitlichen Stand
* herstellen will (z. B. bei einem Schemawechsel des Payloads). Fuer den
* laufenden Betrieb genuegt der naechtliche Lauf ohne `--force`.
*/
/**
* Execute the console command.
*/
public function handle(BackofficeDashboardService $dashboardService): int
{
$months = $this->monthsToStore();
$force = (bool) $this->option('force');
if ($months === []) {
$this->info('Keine abgeschlossenen Monate zum Speichern.');
return self::SUCCESS;
}
$userQuery = User::query()
->where('admin', '>=', 1)
->where('admin', '<', 4)
->whereNull('deleted_at');
if ($userId = $this->option('user')) {
$userQuery->where('id', $userId);
}
$users = $userQuery->get();
$this->info('Berechne Backoffice-Snapshots fuer '.$users->count().' User und '.count($months).' Monate...');
$bar = $this->output->createProgressBar($users->count());
$bar->start();
$stored = 0;
$skipped = 0;
foreach ($users as $user) {
foreach ($months as [$year, $month]) {
$exists = BackofficeStatisticsSnapshot::query()
->where('user_id', $user->id)
->where('year', $year)
->where('month', $month)
->exists();
if ($exists && ! $force) {
$skipped++;
continue;
}
$dashboardService->storeSnapshot($user, $month, $year);
$stored++;
}
$bar->advance();
gc_collect_cycles();
}
$bar->finish();
$this->newLine();
$this->info("Fertig. Gespeichert: {$stored}, uebersprungen: {$skipped}");
return self::SUCCESS;
}
/**
* @return array<array{int, int}>
*/
private function monthsToStore(): array
{
$monthOption = $this->option('month');
$yearOption = $this->option('year');
if ($monthOption && $yearOption) {
$month = max(1, min(12, (int) $monthOption));
$year = (int) $yearOption;
if (! $this->isClosedMonth($month, $year)) {
return [];
}
return [[$year, $month]];
}
$months = [];
$cursor = Carbon::create(2026, 1, 1)->startOfMonth();
$lastClosedMonth = now()->startOfMonth()->subMonth();
while ($cursor->lte($lastClosedMonth)) {
$months[] = [(int) $cursor->year, (int) $cursor->month];
$cursor->addMonth();
}
return $months;
}
private function isClosedMonth(int $month, int $year): bool
{
return Carbon::create($year, $month, 1)->endOfMonth()->lt(now()->startOfMonth());
}
}