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>
140 lines
4.3 KiB
PHP
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());
|
|
}
|
|
}
|