K11 aus dem September-Protokoll (Tickets C1 und C2 im Entwicklungsplan), live seit 14.09.2026. C1 – Statistik rechnet wie die Provision - Standard-Baum ist der Provisionsbaum (Mitglieder wie TreeCalcBot: Mitgliedsebene, Abrechnungskonto, active_date; ausgefilterte Knoten kappen ihren Unterbaum, max. 21 Linien). Gesamte Downline bleibt per Umschalter erreichbar, Wahl in der Session. - Punkte-Definition: Provisionspunkte (TP + Shop), reine Karrierepunkte ausgewiesen statt eingerechnet, negative Monatssalden je User wie in der Provision auf 0 gekappt. - Abgeschlossene Monate nehmen die Mitglieder je Linie aus dem gespeicherten Provisionslauf (user_businesses), Snapshots je Baum-Definition (Payload-Version 3, Migration). - Struktur-Modal: Gesamt aus den Linien-Punkten mit Dezimalen statt der int-Spalte total_pp. - Messlauf statistics:compare-trees (Downline / Provisionsbaum / Struktur je VIP, Laufstand-Drift). C2 – Laufstand-Race (Provision zu niedrig bei zeitgleichen Buchungen) - Neuberechnung liest die Zeilen des Beraters sperrend (FOR UPDATE), Index (user_id, year, month). - Paid-Pfad der Payone- und Stripe-Webhooks als DB::transaction mit 3 Anlaeufen; Deadlock und Lock-Timeout aus der Rechnungserstellung werden nicht mehr geschluckt. - Sicherheitsnetz sales:repair-running-totals taeglich 02:45 vor dem Provisionslauf; abgeschlossene Monate werden nicht angefasst (Entscheid 14.09.: keine Nachzahlung fuer 06/08). Doku: Entwicklungsplan zum Protokoll, Deploy-Notiz 14.09. (inkl. Migrations-Stolperstein: Migrationen tragen Werte als Literale, nicht als Konstanten aus dem Anwendungscode). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
110 lines
3.9 KiB
PHP
110 lines
3.9 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Services\BusinessPlan\SalesVolumeRunningTotalService;
|
|
use App\User;
|
|
use Illuminate\Console\Command;
|
|
|
|
/**
|
|
* Sicherheitsnetz gegen Laufstand-Drift (Ticket C2): laeuft taeglich um 02:45,
|
|
* also vor dem Provisionslauf `business:store-optimized` um 03:00, und rechnet
|
|
* die Laufstaende aller User neu, deren letzte Zeile in `user_sales_volumes`
|
|
* nicht zur Zeilensumme passt. Ohne Argumente werden der laufende Monat und —
|
|
* solange er noch nicht abgerechnet ist — der Vormonat geprueft.
|
|
*
|
|
* Hintergrund: {@see SalesVolumeRunningTotalService}.
|
|
*/
|
|
class SalesRepairRunningTotals extends Command
|
|
{
|
|
protected $signature = 'sales:repair-running-totals
|
|
{month? : Monat (Standard: laufender Monat und, falls offen, Vormonat)}
|
|
{year? : Jahr}
|
|
{--dry-run : Nur anzeigen, nichts schreiben}
|
|
{--force : Auch einen bereits abgerechneten Monat neu rechnen (Nachberechnung!)}';
|
|
|
|
protected $description = 'Laufstaende in user_sales_volumes gegen die Zeilensumme pruefen und abweichende User neu berechnen';
|
|
|
|
public function handle(SalesVolumeRunningTotalService $service): int
|
|
{
|
|
$dryRun = (bool) $this->option('dry-run');
|
|
$months = $this->months($service);
|
|
|
|
if ($months === null) {
|
|
return self::FAILURE;
|
|
}
|
|
|
|
foreach ($months as ['month' => $month, 'year' => $year]) {
|
|
$drift = $dryRun
|
|
? $service->detectDrift($month, $year)
|
|
: $service->repair($month, $year);
|
|
|
|
$label = sprintf('%02d/%d', $month, $year);
|
|
|
|
if ($drift === []) {
|
|
$this->info("Laufstaende {$label}: keine Abweichung.");
|
|
|
|
continue;
|
|
}
|
|
|
|
$users = User::query()->whereIn('id', array_keys($drift))->get()->keyBy('id');
|
|
|
|
$this->warn(sprintf(
|
|
'Laufstaende %s: %d User mit Drift%s',
|
|
$label,
|
|
count($drift),
|
|
$dryRun ? ' (DRY-RUN, nichts geschrieben)' : ', neu berechnet'
|
|
));
|
|
$this->table(
|
|
['User', 'E-Mail', 'Zeilensumme', 'Laufstand vorher', 'Diff'],
|
|
collect($drift)->map(fn (array $values, int $userId) => [
|
|
$userId,
|
|
$users->get($userId)?->email ?? '-',
|
|
number_format($values['rows'], 2, ',', '.'),
|
|
number_format($values['running'], 2, ',', '.'),
|
|
number_format($values['running'] - $values['rows'], 2, ',', '.'),
|
|
])->values()->all()
|
|
);
|
|
|
|
\Log::channel('cron')->warning('sales:repair-running-totals: Laufstand-Drift', [
|
|
'month' => $month,
|
|
'year' => $year,
|
|
'dry_run' => $dryRun,
|
|
'users' => $drift,
|
|
]);
|
|
}
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array{month: int, year: int}>|null
|
|
*/
|
|
private function months(SalesVolumeRunningTotalService $service): ?array
|
|
{
|
|
if (! $this->argument('month')) {
|
|
return $service->monthsToGuard();
|
|
}
|
|
|
|
$month = (int) $this->argument('month');
|
|
$year = (int) ($this->argument('year') ?: now()->year);
|
|
|
|
if ($month < 1 || $month > 12 || $year < 2020) {
|
|
$this->error('Ungueltiger Monat/Jahr.');
|
|
|
|
return null;
|
|
}
|
|
|
|
if (SalesVolumeRunningTotalService::isMonthClosed($month, $year) && ! $this->option('dry-run') && ! $this->option('force')) {
|
|
$this->error(sprintf(
|
|
'Monat %02d/%d ist abgerechnet — die Laufstaende sind so in die Provision eingegangen. Neu rechnen nur mit --force (aendert keine Gutschriften, nur die Datenbasis).',
|
|
$month,
|
|
$year
|
|
));
|
|
|
|
return null;
|
|
}
|
|
|
|
return [['month' => $month, 'year' => $year]];
|
|
}
|
|
}
|