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
|
|
@ -28,6 +28,19 @@ class BackofficeStoreStatisticsSnapshots extends Command
|
|||
*/
|
||||
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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Services\Backoffice;
|
||||
|
||||
use App\Models\BackofficeStatisticsSnapshot;
|
||||
use App\Models\ShoppingOrderItem;
|
||||
use App\Models\UserAbo;
|
||||
use App\Models\UserLevel;
|
||||
use App\Models\UserSalesVolume;
|
||||
|
|
@ -12,8 +13,43 @@ use Carbon\Carbon;
|
|||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Monatsuebersicht der Backoffice-Statistik.
|
||||
*
|
||||
* ## Warum abgeschlossene Monate eingefroren sind (Kundenentscheidung 2026-09-10)
|
||||
*
|
||||
* Am ersten Tag des Folgemonats laeuft die Monatsabrechnung: Provisionen werden
|
||||
* berechnet, Gutschriften ausgestellt, der Monat ist damit kaufmaennisch
|
||||
* abgeschlossen. Aus demselben Grund fliessen auch keine Stornos mehr rueckwirkend
|
||||
* in einen abgeschlossenen Monat ({@see SalesPointsVolume::isStornoMonthOpen()}).
|
||||
*
|
||||
* Die Statistik muss dieser Logik folgen, denn die Team-Struktur ist beweglich:
|
||||
* Teampartner kommen dazu, wechseln den Sponsor oder fallen weg. Wuerde ein
|
||||
* vergangener Monat weiter aus heutigen Daten gerechnet, saehe eine Beraterin dort
|
||||
* spaeter andere Zahlen als in der Abrechnung, die sie fuer diesen Monat erhalten
|
||||
* hat — und die Ursache waere von aussen nicht erkennbar.
|
||||
*
|
||||
* Eingefroren wird deshalb der **gesamte** Monatsstand, nicht nur die Summen:
|
||||
* Punkte, Umsaetze, Abo-Bestaende, die Mitglieder je Linie (`user_ids`) und die
|
||||
* Abos je Linie (`abo_ids`). Aus diesen beiden ID-Listen baut
|
||||
* {@see BackofficeDrilldownService} die Detaillisten, sodass jede verlinkte Liste
|
||||
* zu der Zahl passt, aus der sie aufgerufen wurde.
|
||||
*
|
||||
* Bewusst NICHT eingefroren werden Stammdaten wie Name oder Karrierestufe: sie
|
||||
* werden zur Anzeigezeit gelesen. Eine Umbenennung soll rueckwirkend sichtbar sein,
|
||||
* eine Strukturaenderung nicht.
|
||||
*/
|
||||
class BackofficeDashboardService
|
||||
{
|
||||
/**
|
||||
* Payload-Schema der gespeicherten Monats-Snapshots.
|
||||
*
|
||||
* 2 — Linienzeilen tragen zusaetzlich `abo_ids`, damit die Detaillisten eines
|
||||
* abgeschlossenen Monats aus demselben eingefrorenen Stand kommen wie die
|
||||
* Zahl darueber.
|
||||
*/
|
||||
public const PAYLOAD_VERSION = 2;
|
||||
|
||||
private const MAX_DEPTH_SAFETY_LIMIT = 100;
|
||||
|
||||
private const LEADER_LEVEL_NAME = 'Vertriebsleiter';
|
||||
|
|
@ -62,8 +98,12 @@ class BackofficeDashboardService
|
|||
->first();
|
||||
|
||||
if ($snapshot) {
|
||||
// Abgeschlossener Monat: der gespeicherte Stand wird unveraendert
|
||||
// ausgeliefert — siehe Klassenkommentar. Frueher wurden hier die
|
||||
// Abo-Bestaende live nachgerechnet; das hat den eingefrorenen Monat
|
||||
// mit heutigen Zahlen vermischt.
|
||||
return $this->withMeta(
|
||||
$this->overlayCurrentAboStock($user, $snapshot->payload),
|
||||
$snapshot->payload,
|
||||
'snapshot',
|
||||
$snapshot->calculated_at?->format('d.m.Y H:i')
|
||||
);
|
||||
|
|
@ -115,6 +155,7 @@ class BackofficeDashboardService
|
|||
private function buildOverview(User $user, int $month, int $year): array
|
||||
{
|
||||
$lineBuckets = $this->lineBuckets($user->id);
|
||||
$recruiter = $this->recruiterRanking($user, $lineBuckets, $month, $year);
|
||||
$lines = [];
|
||||
$totals = $this->emptyLine(0, []);
|
||||
|
||||
|
|
@ -133,6 +174,11 @@ class BackofficeDashboardService
|
|||
return [
|
||||
'month' => $month,
|
||||
'year' => $year,
|
||||
// Schema-Version des Payloads: ab 2 enthalten die Linienzeilen `abo_ids`,
|
||||
// aus denen die Detaillisten eines abgeschlossenen Monats gebaut werden.
|
||||
// Aeltere Snapshots bleiben lesbar, ihre Detaillisten fallen auf die
|
||||
// Monatsend-Rueckrechnung zurueck ({@see BackofficeDrilldownService}).
|
||||
'payload_version' => self::PAYLOAD_VERSION,
|
||||
'metric_labels' => $this->metricLabels(),
|
||||
'lines' => $lines,
|
||||
'totals' => $totals,
|
||||
|
|
@ -140,88 +186,13 @@ class BackofficeDashboardService
|
|||
'new_customer_abos' => $this->newCustomerAboRanking($user, $lineBuckets, $month, $year),
|
||||
'leader_abos' => $this->leaderAboOverview($lineBuckets),
|
||||
'top_customer_abos' => $this->topCustomerAboRanking($user, $lineBuckets, $month, $year),
|
||||
'recruiter_ranking' => $this->recruiterRanking($user, $lineBuckets, $month, $year),
|
||||
'recruiter_ranking' => $recruiter['rows'],
|
||||
// Eigener Payload-Schlüssel statt geänderter Listenform: ältere
|
||||
// Snapshots kennen nur recruiter_ranking und bleiben lesbar.
|
||||
'recruiter_totals' => $recruiter['totals'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Überlagert einen Snapshot-Payload mit den tagesaktuellen Abo-Bestandszahlen
|
||||
* (Kundenentscheid F7): Teamabos/Teamkundenabos je Linie + Summe sowie die
|
||||
* Führungskräfte- und Top-10-Listen sind Bestands-Kennzahlen ohne Monatsbezug
|
||||
* und werden live nachgerechnet. Monatsbezogene Werte (Punkte, Umsätze,
|
||||
* Neupartner, "+N neu", neue Kundenabos) bleiben eingefroren.
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function overlayCurrentAboStock(User $user, array $payload): array
|
||||
{
|
||||
$lineBuckets = $this->lineBuckets($user->id);
|
||||
|
||||
$totalPartnerAbos = 0;
|
||||
$totalCustomerAbos = 0;
|
||||
|
||||
$snapshotLines = collect($payload['lines'] ?? [])
|
||||
->map(fn ($row, $key) => (int) ($row['line'] ?? $key))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
foreach ($payload['lines'] ?? [] as $key => $row) {
|
||||
$line = (int) ($row['line'] ?? $key);
|
||||
$userIds = ($lineBuckets[$line] ?? collect())->pluck('id')->map(fn ($id) => (int) $id)->all();
|
||||
|
||||
$partnerAbos = $userIds === [] ? 0 : $this->activeAboQuery()->whereIn('user_id', $userIds)->where('is_for', 'me')->count();
|
||||
$customerAbos = $userIds === [] ? 0 : $this->activeAboQuery()->whereIn('member_id', $userIds)->where('is_for', 'ot')->count();
|
||||
|
||||
$payload['lines'][$key]['team_partner_abos'] = $partnerAbos;
|
||||
$payload['lines'][$key]['team_customer_abos'] = $customerAbos;
|
||||
|
||||
$totalPartnerAbos += $partnerAbos;
|
||||
$totalCustomerAbos += $customerAbos;
|
||||
}
|
||||
|
||||
// Linien, die erst nach dem Snapshot entstanden sind (Team ist heute tiefer),
|
||||
// tragen ebenfalls aktuellen Abo-Bestand: als neue Zeile anhängen, Monatswerte 0.
|
||||
foreach ($lineBuckets as $line => $users) {
|
||||
if (in_array((int) $line, $snapshotLines, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$userIds = $users->pluck('id')->map(fn ($id) => (int) $id)->all();
|
||||
if ($userIds === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$partnerAbos = $this->activeAboQuery()->whereIn('user_id', $userIds)->where('is_for', 'me')->count();
|
||||
$customerAbos = $this->activeAboQuery()->whereIn('member_id', $userIds)->where('is_for', 'ot')->count();
|
||||
|
||||
if ($partnerAbos === 0 && $customerAbos === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$row = $this->emptyLine((int) $line, $userIds);
|
||||
$row['team_partner_abos'] = $partnerAbos;
|
||||
$row['team_customer_abos'] = $customerAbos;
|
||||
$payload['lines'][(int) $line] = $row;
|
||||
|
||||
$totalPartnerAbos += $partnerAbos;
|
||||
$totalCustomerAbos += $customerAbos;
|
||||
}
|
||||
|
||||
$payload['lines'] = collect($payload['lines'] ?? [])->sortBy('line')->all();
|
||||
|
||||
if (isset($payload['totals'])) {
|
||||
$payload['totals']['team_partner_abos'] = $totalPartnerAbos;
|
||||
$payload['totals']['team_customer_abos'] = $totalCustomerAbos;
|
||||
}
|
||||
|
||||
$payload['leader_abos'] = $this->leaderAboOverview($lineBuckets);
|
||||
$payload['top_customer_abos'] = $this->topCustomerAboRanking($user, $lineBuckets, (int) ($payload['month'] ?? now()->month), (int) ($payload['year'] ?? now()->year));
|
||||
$payload['abo_stock_live'] = true;
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overview
|
||||
* @return array<string, mixed>
|
||||
|
|
@ -343,16 +314,32 @@ class BackofficeDashboardService
|
|||
|
||||
$salesSummary = $this->salesSummary($userIds, $month, $year);
|
||||
|
||||
// Nicht nur zaehlen, sondern die Abo-IDs mitschreiben: der Snapshot ist der
|
||||
// eingefrorene Monatsstand, und die Detailliste hinter der Zahl muss genau
|
||||
// diese Abos zeigen — auch wenn eines davon spaeter gekuendigt oder geloescht
|
||||
// wird. Ohne die IDs liesse sich die Liste nur aus dem heutigen Bestand
|
||||
// rekonstruieren und wuerde von ihrer eigenen Kachel abweichen.
|
||||
$partnerAboIds = $this->aboIds($userIds, 'user_id', 'me');
|
||||
$customerAboIds = $this->aboIds($userIds, 'member_id', 'ot');
|
||||
$partnerAboIdsNew = $this->aboIds($userIds, 'user_id', 'me', $month, $year);
|
||||
$customerAboIdsNew = $this->aboIds($userIds, 'member_id', 'ot', $month, $year);
|
||||
|
||||
return [
|
||||
'line' => $line,
|
||||
'label' => 'Linie '.$line,
|
||||
'user_ids' => $userIds,
|
||||
'consultants' => $this->activeConsultants($users),
|
||||
'new_partners' => $this->newPartners($users, $month, $year),
|
||||
'team_partner_abos' => $this->activeAboQuery()->whereIn('user_id', $userIds)->where('is_for', 'me')->count(),
|
||||
'team_partner_abos_new' => $this->newAboCount($userIds, 'user_id', 'me', $month, $year),
|
||||
'team_customer_abos' => $this->activeAboQuery()->whereIn('member_id', $userIds)->where('is_for', 'ot')->count(),
|
||||
'team_customer_abos_new' => $this->newAboCount($userIds, 'member_id', 'ot', $month, $year),
|
||||
'team_partner_abos' => count($partnerAboIds),
|
||||
'team_partner_abos_new' => count($partnerAboIdsNew),
|
||||
'team_customer_abos' => count($customerAboIds),
|
||||
'team_customer_abos_new' => count($customerAboIdsNew),
|
||||
'abo_ids' => [
|
||||
'team_partner_abos' => $partnerAboIds,
|
||||
'team_partner_abos_new' => $partnerAboIdsNew,
|
||||
'team_customer_abos' => $customerAboIds,
|
||||
'team_customer_abos_new' => $customerAboIdsNew,
|
||||
],
|
||||
'own_points' => $salesSummary['own_points'],
|
||||
'external_points' => $salesSummary['external_points'],
|
||||
'customer_abo_points' => $salesSummary['customer_abo_points'],
|
||||
|
|
@ -436,19 +423,39 @@ class BackofficeDashboardService
|
|||
->whereNotIn('status', [4, 5, 6]);
|
||||
}
|
||||
|
||||
/**
|
||||
* IDs der aktiven Abos einer Mitgliedermenge. Mit Monat/Jahr nur die in diesem
|
||||
* Monat gestarteten ("+N neu").
|
||||
*
|
||||
* @param int[] $userIds
|
||||
* @return int[]
|
||||
*/
|
||||
private function aboIds(array $userIds, string $userColumn, string $isFor, ?int $month = null, ?int $year = null): array
|
||||
{
|
||||
if ($userIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$query = $this->activeAboQuery()
|
||||
->whereIn($userColumn, $userIds)
|
||||
->where('is_for', $isFor);
|
||||
|
||||
if ($month !== null && $year !== null) {
|
||||
$query->whereBetween('start_date', [
|
||||
Carbon::create($year, $month, 1)->startOfMonth(),
|
||||
Carbon::create($year, $month, 1)->endOfMonth(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $query->pluck('id')->map(fn ($id) => (int) $id)->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[] $userIds
|
||||
*/
|
||||
private function newAboCount(array $userIds, string $userColumn, string $isFor, int $month, int $year): int
|
||||
{
|
||||
$startDate = Carbon::create($year, $month, 1)->startOfMonth();
|
||||
$endDate = Carbon::create($year, $month, 1)->endOfMonth();
|
||||
|
||||
return $this->activeAboQuery()
|
||||
->whereIn($userColumn, $userIds)
|
||||
->where('is_for', $isFor)
|
||||
->whereBetween('start_date', [$startDate, $endDate])
|
||||
->count();
|
||||
return count($this->aboIds($userIds, $userColumn, $isFor, $month, $year));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -736,18 +743,30 @@ class BackofficeDashboardService
|
|||
$aboCounts = $this->customerAboCounts($memberIds);
|
||||
$targetPoints = $this->customerAboTargetPoints($memberIds);
|
||||
$actualPoints = $this->customerAboActualPoints($memberIds, $month, $year);
|
||||
$addonPoints = $this->customerAboAddonPoints($memberIds, $month, $year);
|
||||
|
||||
$rows = collect($usersWithLine)
|
||||
->map(fn (array $entry, int $userId) => [
|
||||
'user_id' => $userId,
|
||||
'name' => $this->userName($entry['user']),
|
||||
'career_level' => $this->careerLevel($entry['user']),
|
||||
'line' => $entry['line'],
|
||||
'is_self' => $entry['line'] === 0,
|
||||
'abo_count' => (int) $aboCounts->get($userId, 0),
|
||||
'points_target' => (float) $targetPoints->get($userId, 0.0),
|
||||
'points_actual' => (float) $actualPoints->get($userId, 0.0),
|
||||
]);
|
||||
->map(function (array $entry, int $userId) use ($aboCounts, $targetPoints, $actualPoints, $addonPoints) {
|
||||
$actual = (float) $actualPoints->get($userId, 0.0);
|
||||
// Einmal-Zusätze aus der Position ableiten, den wiederkehrenden
|
||||
// Anteil als Rest: so ergeben die beiden Spalten IMMER die
|
||||
// angezeigte Gesamtsumme, auch wenn Positions- und
|
||||
// Zeilensummen in Altdaten einmal auseinanderfallen.
|
||||
$addon = min(max((float) $addonPoints->get($userId, 0.0), 0.0), $actual);
|
||||
|
||||
return [
|
||||
'user_id' => $userId,
|
||||
'name' => $this->userName($entry['user']),
|
||||
'career_level' => $this->careerLevel($entry['user']),
|
||||
'line' => $entry['line'],
|
||||
'is_self' => $entry['line'] === 0,
|
||||
'abo_count' => (int) $aboCounts->get($userId, 0),
|
||||
'points_target' => (float) $targetPoints->get($userId, 0.0),
|
||||
'points_actual' => $actual,
|
||||
'points_addon' => round($addon, 2),
|
||||
'points_recurring' => round($actual - $addon, 2),
|
||||
];
|
||||
});
|
||||
|
||||
return [
|
||||
'by_count' => $rows
|
||||
|
|
@ -810,6 +829,84 @@ class BackofficeDashboardService
|
|||
->map(fn ($points) => (float) $points);
|
||||
}
|
||||
|
||||
/**
|
||||
* Von den abgerechneten Kundenabo-Punkten der Anteil, der aus EINMALIG zum
|
||||
* Abo dazubestellten Positionen stammt (Kundenwunsch 2026-09-07:
|
||||
* "1231 abgerechnet – davon 1000 reine Abo-Punkte – 231 einmalige").
|
||||
*
|
||||
* Grundlage ist `shopping_order_items.is_abo_addon`. Die Positionspunkte sind
|
||||
* Stückpunkte zum Bestellzeitpunkt, die Zeilensumme also `points * qty` —
|
||||
* gegen die Produktivdaten geprüft: die Positionssummen je Bestellung stimmen
|
||||
* mit `user_sales_volumes.points` überein.
|
||||
*
|
||||
* Bewusst zweistufig statt als ein JOIN: eine Bestellung darf pro Berater nur
|
||||
* EINMAL zählen, ein zusätzlicher Join auf die Positionen würde die Zeilen
|
||||
* vervielfachen, sobald zu einer Bestellung mehrere Umsatzzeilen existieren.
|
||||
*
|
||||
* Einschränkung: `is_abo_addon` gibt es erst seit 06/2026. Für ältere
|
||||
* Bestellungen ist der Wert false — die Aufteilung zeigt dort alles als
|
||||
* wiederkehrend.
|
||||
*
|
||||
* @param int[] $memberIds
|
||||
* @return Collection<int, float>
|
||||
*/
|
||||
private function customerAboAddonPoints(array $memberIds, int $month, int $year): Collection
|
||||
{
|
||||
if ($memberIds === []) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
// Je Berater und Bestellung sowohl die gebuchte Summe (netto, Stornos also
|
||||
// gegengerechnet) als auch die urspruenglich positive Summe: aus deren
|
||||
// Verhaeltnis ergibt sich, wie viel von der Bestellung im Monat stehen
|
||||
// geblieben ist.
|
||||
$pairs = UserSalesVolume::query()
|
||||
->join('shopping_orders', 'shopping_orders.id', '=', 'user_sales_volumes.shopping_order_id')
|
||||
->whereIn('user_sales_volumes.user_id', $memberIds)
|
||||
->where('user_sales_volumes.month', $month)
|
||||
->where('user_sales_volumes.year', $year)
|
||||
->where('shopping_orders.is_abo', 1)
|
||||
->whereRaw(SalesVolumePoints::shopPredicate())
|
||||
->groupBy('user_sales_volumes.user_id', 'shopping_orders.id')
|
||||
->selectRaw('user_sales_volumes.user_id, shopping_orders.id as shopping_order_id')
|
||||
->selectRaw('SUM(user_sales_volumes.points) as net_points')
|
||||
->selectRaw('SUM(CASE WHEN user_sales_volumes.points > 0 THEN user_sales_volumes.points ELSE 0 END) as gross_points')
|
||||
->get();
|
||||
|
||||
if ($pairs->isEmpty()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$addonByOrder = ShoppingOrderItem::query()
|
||||
->whereIn('shopping_order_id', $pairs->pluck('shopping_order_id')->unique()->all())
|
||||
->addonItems()
|
||||
->groupBy('shopping_order_id')
|
||||
->selectRaw('shopping_order_id, COALESCE(SUM(points * qty), 0) as points')
|
||||
->pluck('points', 'shopping_order_id');
|
||||
|
||||
$byUser = [];
|
||||
foreach ($pairs as $pair) {
|
||||
$addon = (float) $addonByOrder->get((int) $pair->shopping_order_id, 0.0);
|
||||
if ($addon <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Anteil der Bestellung, der im Monat tatsaechlich stehen geblieben ist.
|
||||
// Vollstorno ergibt 0, eine unstornierte Bestellung 1. Ein Teilstorno wird
|
||||
// anteilig verrechnet: welche Position storniert wurde, geht aus der
|
||||
// Stornobuchung nicht hervor — sie hat keine Positionsaufteilung.
|
||||
$gross = (float) $pair->gross_points;
|
||||
$factor = $gross > 0 ? max(0.0, min(1.0, (float) $pair->net_points / $gross)) : 0.0;
|
||||
|
||||
$points = $addon * $factor;
|
||||
if ($points > 0) {
|
||||
$byUser[(int) $pair->user_id] = ($byUser[(int) $pair->user_id] ?? 0.0) + $points;
|
||||
}
|
||||
}
|
||||
|
||||
return collect($byUser);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Recruiter des Monats": Wer im Team (inklusive des Statistik-Nutzers
|
||||
* selbst, Linie 0) hat im gewählten Monat wie viele Neupartner gesponsert?
|
||||
|
|
@ -828,15 +925,22 @@ class BackofficeDashboardService
|
|||
* Sortiert nach Anzahl absteigend (Volumen als Tiebreaker); nur Sponsoren
|
||||
* mit mindestens einem Neupartner.
|
||||
*
|
||||
* `totals` (Kundenwunsch 2026-09-07) ist bewusst NICHT die Spaltensumme:
|
||||
* `volume_points` enthält je Sponsor das Kettenvolumen, ein Neupartner steckt
|
||||
* also gleichzeitig in der Zeile seines Sponsors und in seiner eigenen. Die
|
||||
* Summe wird deshalb einmal über die deduplizierte Neupartner-Menge gerechnet
|
||||
* und ist entsprechend kleiner als die Spalte darüber.
|
||||
*
|
||||
* @param array<int, Collection<int, User>> $lineBuckets
|
||||
* @return array<int, array{user_id: int, name: string, career_level: string, line: int, is_self: bool, new_partner_count: int, volume_points: float, partners: array<int, array{user_id: int, name: string, career_level: string, total_points: float, new_partner_count: int, children: array<int, array{user_id: int, name: string, career_level: string, total_points: float}>}>}>
|
||||
* @return array{rows: array<int, array{user_id: int, name: string, career_level: string, line: int, is_self: bool, new_partner_count: int, volume_points: float, partners: array<int, array{user_id: int, name: string, career_level: string, total_points: float, new_partner_count: int, children: array<int, array{user_id: int, name: string, career_level: string, total_points: float}>}>}>, totals: array{new_partner_count: int, volume_points: float}}
|
||||
*/
|
||||
private function recruiterRanking(User $rootUser, array $lineBuckets, int $month, int $year): array
|
||||
{
|
||||
$emptyTotals = ['new_partner_count' => 0, 'volume_points' => 0.0];
|
||||
$usersByLine = $this->usersWithLine($lineBuckets, $rootUser);
|
||||
|
||||
if ($usersByLine === []) {
|
||||
return [];
|
||||
return ['rows' => [], 'totals' => $emptyTotals];
|
||||
}
|
||||
|
||||
$newPartnersBySponsor = [];
|
||||
|
|
@ -856,16 +960,22 @@ class BackofficeDashboardService
|
|||
}
|
||||
|
||||
if ($newPartnersBySponsor === []) {
|
||||
return [];
|
||||
return ['rows' => [], 'totals' => $emptyTotals];
|
||||
}
|
||||
|
||||
$newPartnerIds = collect($newPartnersBySponsor)
|
||||
->flatMap(fn (array $partners) => collect($partners)->map(fn (User $partner) => (int) $partner->id))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$pointsByUser = $this->totalPointsByUser($newPartnerIds, $month, $year);
|
||||
|
||||
$totals = [
|
||||
'new_partner_count' => count($newPartnerIds),
|
||||
'volume_points' => round((float) collect($newPartnerIds)->sum(fn (int $id) => (float) $pointsByUser->get($id, 0.0)), 2),
|
||||
];
|
||||
|
||||
$chainVolume = function (int $sponsorId, array $visited) use (&$chainVolume, $newPartnersBySponsor, $pointsByUser): float {
|
||||
$volume = 0.0;
|
||||
|
||||
|
|
@ -933,10 +1043,13 @@ class BackofficeDashboardService
|
|||
];
|
||||
}
|
||||
|
||||
return collect($rows)
|
||||
->sortBy([['new_partner_count', 'desc'], ['volume_points', 'desc']])
|
||||
->values()
|
||||
->all();
|
||||
return [
|
||||
'rows' => collect($rows)
|
||||
->sortBy([['new_partner_count', 'desc'], ['volume_points', 'desc']])
|
||||
->values()
|
||||
->all(),
|
||||
'totals' => $totals,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1002,9 +1115,14 @@ class BackofficeDashboardService
|
|||
return $name !== '' ? $name : ($user->email ?: '#'.$user->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Karriere-Level in der Sprache des Betrachters — dieselbe Auflösung wie in
|
||||
* den übrigen Team-Auswertungen ({@see TeamAboOverviewService}, Firstlines,
|
||||
* Jahresgebühr-Liste), sonst steht in einer Karte der deutsche Name.
|
||||
*/
|
||||
private function careerLevel(User $user): string
|
||||
{
|
||||
return $user->user_level?->name ?: ($user->m_level ? 'Level '.$user->m_level : '-');
|
||||
return $user->user_level?->getLang('name') ?: ($user->m_level ? 'Level '.$user->m_level : '-');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Services\Backoffice;
|
||||
|
||||
use App\Models\BackofficeStatisticsSnapshot;
|
||||
use App\Models\UserAbo;
|
||||
use App\Models\UserSalesVolume;
|
||||
use App\User;
|
||||
|
|
@ -26,17 +27,30 @@ class BackofficeDrilldownService
|
|||
|
||||
$onlyNew = $onlyNew && in_array($metric, ['team_partner_abos', 'team_customer_abos'], true);
|
||||
|
||||
$userIds = $this->dashboardService->lineUserIds($viewer->id, $line);
|
||||
// Abgeschlossener Monat: Mitglieder und Abos kommen aus dem eingefrorenen
|
||||
// Snapshot, damit die Liste zu der Zahl passt, aus der sie aufgerufen wurde.
|
||||
// Ohne das rechnet die Liste mit der heutigen Struktur — ein spaeter
|
||||
// hinzugekommener oder verschobener Teampartner taucht dann in einem
|
||||
// laengst abgerechneten Monat auf (oder verschwindet daraus).
|
||||
$frozen = $this->frozenLines($viewer, $month, $year);
|
||||
|
||||
$userIds = $frozen !== null
|
||||
? $this->frozenUserIds($frozen, $line)
|
||||
: $this->dashboardService->lineUserIds($viewer->id, $line);
|
||||
|
||||
if ($metric === 'shop_1000' && $line === 0) {
|
||||
$userIds[] = (int) $viewer->id;
|
||||
}
|
||||
|
||||
$frozenAboIds = $frozen !== null
|
||||
? $this->frozenAboIds($frozen, $line, $metric, $onlyNew)
|
||||
: null;
|
||||
|
||||
$rows = match ($metric) {
|
||||
'consultants' => $this->consultantRows($userIds),
|
||||
'new_partners' => $this->newPartnerRows($userIds, $month, $year),
|
||||
'team_partner_abos' => $this->partnerAboRows($userIds, $month, $year, $onlyNew),
|
||||
'team_customer_abos' => $this->customerAboRows($userIds, $month, $year, $onlyNew),
|
||||
'team_partner_abos' => $this->partnerAboRows($userIds, $month, $year, $onlyNew, $frozenAboIds),
|
||||
'team_customer_abos' => $this->customerAboRows($userIds, $month, $year, $onlyNew, $frozenAboIds),
|
||||
'own_points', 'external_points', 'customer_abo_points', 'customer_single_order_points', 'customer_other_points', 'total_points', 'shop_1000' => $this->pointsRows($userIds, $month, $year, $metric),
|
||||
default => [],
|
||||
};
|
||||
|
|
@ -119,7 +133,9 @@ class BackofficeDrilldownService
|
|||
'is_account_active' => Carbon::parse($user->payment_account)->isFuture(),
|
||||
'account_status' => Carbon::parse($user->payment_account)->isFuture() ? 'Aktiv' : 'Abgelaufen',
|
||||
'active_date' => $this->formatDate($user->active_date),
|
||||
'active_date_sort' => $this->sortDate($user->active_date),
|
||||
'payment_account' => $this->formatDate($user->payment_account),
|
||||
'payment_account_sort' => $this->sortDate($user->payment_account),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
|
@ -152,12 +168,17 @@ class BackofficeDrilldownService
|
|||
'user_id' => $user->id,
|
||||
'name' => $this->userName($user),
|
||||
'email' => $user->email,
|
||||
// Telefon mit Mobil-Fallback, gleiche Regel wie auf der
|
||||
// Firstlines-Seite (Kundenwunsch 2026-09-07: zum Nachfassen).
|
||||
'phone' => trim(($user->account?->phone ?? '') !== '' ? (string) $user->account->phone : (string) ($user->account?->mobil ?? '')),
|
||||
'career_level' => $this->careerLevel($user),
|
||||
'sponsor' => $user->m_sponsor && $sponsors->has((int) $user->m_sponsor) ? $this->userName($sponsors->get((int) $user->m_sponsor)) : '-',
|
||||
'customer_points' => (float) ($monthlyPoints->get((int) $user->id)['external_points'] ?? 0.0),
|
||||
'total_points' => (float) ($monthlyPoints->get((int) $user->id)['total_points'] ?? 0.0),
|
||||
'active_date' => $this->formatDate($user->active_date),
|
||||
'active_date_sort' => $this->sortDate($user->active_date),
|
||||
'payment_account' => $this->formatDate($user->payment_account),
|
||||
'payment_account_sort' => $this->sortDate($user->payment_account),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
|
@ -193,17 +214,97 @@ class BackofficeDrilldownService
|
|||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Die eingefrorenen Linienzeilen eines abgeschlossenen Monats, oder null, wenn
|
||||
* der Monat noch laeuft bzw. kein Snapshot (oder ein aelterer ohne `abo_ids`)
|
||||
* vorliegt. Aeltere Payloads fallen bewusst auf die Live-Auswertung zurueck,
|
||||
* statt eine Genauigkeit vorzutaeuschen, die sie nicht haben.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>|null
|
||||
*/
|
||||
private function frozenLines(User $viewer, int $month, int $year): ?array
|
||||
{
|
||||
$snapshot = BackofficeStatisticsSnapshot::query()
|
||||
->where('user_id', $viewer->id)
|
||||
->where('month', $month)
|
||||
->where('year', $year)
|
||||
->first();
|
||||
|
||||
if (! $snapshot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$payload = $snapshot->payload;
|
||||
|
||||
if ((int) ($payload['payload_version'] ?? 1) < BackofficeDashboardService::PAYLOAD_VERSION) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return is_array($payload['lines'] ?? null) ? $payload['lines'] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mitglieder einer Linie zum Monatsabschluss; Linie 0 = das ganze Team.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $frozen
|
||||
* @return int[]
|
||||
*/
|
||||
private function frozenUserIds(array $frozen, int $line): array
|
||||
{
|
||||
$ids = [];
|
||||
|
||||
foreach ($frozen as $key => $row) {
|
||||
if ($line !== 0 && (int) ($row['line'] ?? $key) !== $line) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($row['user_ids'] ?? [] as $id) {
|
||||
$ids[] = (int) $id;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
/**
|
||||
* Abos einer Linie zum Monatsabschluss; Linie 0 = das ganze Team. Gibt es zu der
|
||||
* Kennzahl keine eingefrorene Liste, wird null zurueckgegeben (Live-Auswertung).
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $frozen
|
||||
* @return int[]|null
|
||||
*/
|
||||
private function frozenAboIds(array $frozen, int $line, string $metric, bool $onlyNew): ?array
|
||||
{
|
||||
if (! in_array($metric, ['team_partner_abos', 'team_customer_abos'], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$key = $metric.($onlyNew ? '_new' : '');
|
||||
$ids = [];
|
||||
|
||||
foreach ($frozen as $index => $row) {
|
||||
if ($line !== 0 && (int) ($row['line'] ?? $index) !== $line) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($row['abo_ids'][$key] ?? [] as $id) {
|
||||
$ids[] = (int) $id;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[] $userIds
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function partnerAboRows(array $userIds, int $month, int $year, bool $onlyNew = false): array
|
||||
private function partnerAboRows(array $userIds, int $month, int $year, bool $onlyNew = false, ?array $frozenAboIds = null): array
|
||||
{
|
||||
return $this->activeAboQuery()
|
||||
return $this->aboScope($frozenAboIds)
|
||||
->with(['user.account', 'user.user_level', 'user_abo_items.product', 'user_abo_orders.shopping_order.shopping_payments.payment_transactions'])
|
||||
->whereIn('user_id', $userIds)
|
||||
->where('is_for', 'me')
|
||||
->when($onlyNew, fn (Builder $query) => $this->whereNewInMonth($query, $month, $year))
|
||||
->when($frozenAboIds === null, fn (Builder $query) => $query->whereIn('user_id', $userIds)->where('is_for', 'me'))
|
||||
->when($frozenAboIds === null && $onlyNew, fn (Builder $query) => $this->whereNewInMonth($query, $month, $year))
|
||||
->orderBy('next_date')
|
||||
->get()
|
||||
->map(fn (UserAbo $abo) => [
|
||||
|
|
@ -215,8 +316,10 @@ class BackofficeDrilldownService
|
|||
'career_level' => $abo->user ? $this->careerLevel($abo->user) : '-',
|
||||
'points' => $abo->getTotalPoints(),
|
||||
'start_date' => $this->formatDate($abo->getRawOriginal('start_date')),
|
||||
'start_date_sort' => $this->sortDate($abo->getRawOriginal('start_date')),
|
||||
'is_new_this_month' => $this->isAboNewInMonth($abo, $month, $year),
|
||||
'next_date' => $this->formatDate($abo->next_date),
|
||||
'next_date' => $this->formatDate($abo->getRawOriginal('next_date')),
|
||||
'next_date_sort' => $this->sortDate($abo->getRawOriginal('next_date')),
|
||||
'deliveries' => $abo->getCountOrders(),
|
||||
'status' => $abo->status,
|
||||
'status_label' => $abo->getStatusType(),
|
||||
|
|
@ -235,13 +338,12 @@ class BackofficeDrilldownService
|
|||
* @param int[] $userIds
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function customerAboRows(array $userIds, int $month, int $year, bool $onlyNew = false): array
|
||||
private function customerAboRows(array $userIds, int $month, int $year, bool $onlyNew = false, ?array $frozenAboIds = null): array
|
||||
{
|
||||
return $this->activeAboQuery()
|
||||
return $this->aboScope($frozenAboIds)
|
||||
->with(['member.account', 'member.user_level', 'user_abo_items.product'])
|
||||
->whereIn('member_id', $userIds)
|
||||
->where('is_for', 'ot')
|
||||
->when($onlyNew, fn (Builder $query) => $this->whereNewInMonth($query, $month, $year))
|
||||
->when($frozenAboIds === null, fn (Builder $query) => $query->whereIn('member_id', $userIds)->where('is_for', 'ot'))
|
||||
->when($frozenAboIds === null && $onlyNew, fn (Builder $query) => $this->whereNewInMonth($query, $month, $year))
|
||||
->orderBy('member_id')
|
||||
->orderBy('next_date')
|
||||
->get()
|
||||
|
|
@ -254,7 +356,7 @@ class BackofficeDrilldownService
|
|||
'abo_id' => $abo->id,
|
||||
'points' => (float) $abo->getTotalPoints(),
|
||||
'start_date' => $this->formatDate($abo->getRawOriginal('start_date')),
|
||||
'next_date' => $this->formatDate($abo->next_date),
|
||||
'next_date' => $this->formatDate($abo->getRawOriginal('next_date')),
|
||||
'is_new_this_month' => $this->isAboNewInMonth($abo, $month, $year),
|
||||
])->values()->all();
|
||||
|
||||
|
|
@ -294,28 +396,32 @@ class BackofficeDrilldownService
|
|||
->selectRaw(SalesVolumePoints::totalPoints().' as total_points')
|
||||
->groupBy('user_sales_volumes.user_id');
|
||||
|
||||
// `<> 0` statt `> 0`: ein Berater mit negativem Monatssaldo (Storno) steckt in
|
||||
// der Uebersichtssumme und muss deshalb auch in der Detailliste auftauchen —
|
||||
// sonst ergibt die Detailsumme etwas anderes als die verlinkte Kachel.
|
||||
// Nur die echte Qualifikationsliste behaelt ihre Schwelle.
|
||||
if ($metric === 'own_points') {
|
||||
$rows->havingRaw(SalesVolumePoints::ownPoints().' > 0');
|
||||
$rows->havingRaw(SalesVolumePoints::ownPoints().' <> 0');
|
||||
}
|
||||
|
||||
if ($metric === 'external_points') {
|
||||
$rows->havingRaw(SalesVolumePoints::externalPoints().' > 0');
|
||||
$rows->havingRaw(SalesVolumePoints::externalPoints().' <> 0');
|
||||
}
|
||||
|
||||
if ($metric === 'customer_abo_points') {
|
||||
$rows->havingRaw(SalesVolumePoints::customerAboPoints().' > 0');
|
||||
$rows->havingRaw(SalesVolumePoints::customerAboPoints().' <> 0');
|
||||
}
|
||||
|
||||
if ($metric === 'customer_single_order_points') {
|
||||
$rows->havingRaw(SalesVolumePoints::customerSingleOrderPoints().' > 0');
|
||||
$rows->havingRaw(SalesVolumePoints::customerSingleOrderPoints().' <> 0');
|
||||
}
|
||||
|
||||
if ($metric === 'customer_other_points') {
|
||||
$rows->havingRaw(SalesVolumePoints::customerOtherPoints().' > 0');
|
||||
$rows->havingRaw(SalesVolumePoints::customerOtherPoints().' <> 0');
|
||||
}
|
||||
|
||||
if ($metric === 'total_points') {
|
||||
$rows->havingRaw(SalesVolumePoints::totalPoints().' > 0');
|
||||
$rows->havingRaw(SalesVolumePoints::totalPoints().' <> 0');
|
||||
}
|
||||
|
||||
if ($metric === 'shop_1000') {
|
||||
|
|
@ -350,6 +456,25 @@ class BackofficeDrilldownService
|
|||
->whereNotIn('status', [4, 5, 6]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Grundabfrage der Abo-Listen: im laufenden Monat der heutige Aktivbestand, in
|
||||
* einem abgeschlossenen Monat exakt die eingefrorenen Abos.
|
||||
*
|
||||
* `withTrashed()` ist dort nicht optional — ein Abo, das der Monat zu Recht
|
||||
* enthaelt, kann inzwischen gekuendigt oder geloescht sein. Ohne den Zusatz
|
||||
* waere die Liste kuerzer als die Zahl, aus der sie aufgerufen wurde.
|
||||
*
|
||||
* @param int[]|null $frozenAboIds
|
||||
*/
|
||||
private function aboScope(?array $frozenAboIds): Builder
|
||||
{
|
||||
if ($frozenAboIds === null) {
|
||||
return $this->activeAboQuery();
|
||||
}
|
||||
|
||||
return UserAbo::query()->withTrashed()->whereIn('id', $frozenAboIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nur Abos, die im gewählten Monat gestartet sind (gleiche Zählweise wie
|
||||
* die "+N neu"-Badges, {@see BackofficeDashboardService::newAboCount()}).
|
||||
|
|
@ -421,4 +546,19 @@ class BackofficeDrilldownService
|
|||
|
||||
return Carbon::parse($date)->format(\Util::formatDateDB());
|
||||
}
|
||||
|
||||
/**
|
||||
* Unformatierter Sortierwert zu einem Datum (A14). Die Anzeige über
|
||||
* {@see formatDate()} ist lokalisiert — bei `en` liefert
|
||||
* {@see \Util::formatDateDB()} `Y-m-d` statt `d.m.Y` —, deshalb bekommt die
|
||||
* Tabelle den Sortierschlüssel getrennt vom Anzeigetext.
|
||||
*/
|
||||
private function sortDate(mixed $date): ?string
|
||||
{
|
||||
if ($date === null || $date === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Carbon::parse($date)->format('Y-m-d');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue