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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@
|
|||
@if (!empty($performance['calculated_at']))
|
||||
<span class="ml-2">Snapshot vom {{ $performance['calculated_at'] }}</span>
|
||||
@endif
|
||||
@if (!empty($statistics['abo_stock_live']))
|
||||
<span class="badge badge-success ml-2" title="Abo-Bestandszahlen (Teamabos, Teamkundenabos, Führungskräfte, Top 10) zeigen den heutigen Stand; Punkte und Umsätze bleiben auf dem Stand des gewählten Monats.">Abo-Bestände tagesaktuell</span>
|
||||
@if (($performance['source'] ?? null) === 'snapshot')
|
||||
<span class="badge badge-secondary ml-2" title="Abgeschlossener Monat: Punkte, Umsätze, Abo-Bestände, Team-Struktur und alle Detaillisten zeigen den Stand zum Monatsabschluss. Spätere Änderungen — Korrekturen, Kündigungen, Sponsorwechsel, neue Teampartner — verändern diesen Monat nicht mehr.">Monat abgeschlossen · Stand eingefroren</span>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
|
@ -205,7 +205,7 @@
|
|||
<td class="font-weight-semibold">{{ $line['label'] }}</td>
|
||||
@foreach ($clickableMetrics as $metric)
|
||||
<td class="text-right">
|
||||
@if ($line[$metric] > 0)
|
||||
@if ($line[$metric] != 0)
|
||||
<a href="{{ route('user_backoffice_statistics_details', [
|
||||
'line' => $line['line'],
|
||||
'metric' => $metric,
|
||||
|
|
@ -216,7 +216,7 @@
|
|||
{{ $formatValue($line[$metric], $metric) }}
|
||||
</a>
|
||||
@php $newMetric = $newAboMetric($line, $metric); @endphp
|
||||
@if ($newMetric && $line[$newMetric] > 0)
|
||||
@if ($newMetric && $line[$newMetric] != 0)
|
||||
<a href="{{ $newBadgeLink($line['line'], $metric) }}"
|
||||
class="badge badge-pill badge-success ml-1 backoffice-statistics-click-badge"
|
||||
title="Neue im Monat anzeigen">+{{ number_format($line[$newMetric], 0, ',', '.') }}</a>
|
||||
|
|
@ -235,7 +235,7 @@
|
|||
<td>{{ $statistics['totals']['label'] }}</td>
|
||||
@foreach ($clickableMetrics as $metric)
|
||||
<td class="text-right">
|
||||
@if ($statistics['totals'][$metric] > 0)
|
||||
@if ($statistics['totals'][$metric] != 0)
|
||||
<a href="{{ route('user_backoffice_statistics_details', [
|
||||
'line' => 0,
|
||||
'metric' => $metric,
|
||||
|
|
@ -246,7 +246,7 @@
|
|||
{{ $formatValue($statistics['totals'][$metric], $metric) }}
|
||||
</a>
|
||||
@php $newMetric = $newAboMetric($statistics['totals'], $metric); @endphp
|
||||
@if ($newMetric && $statistics['totals'][$newMetric] > 0)
|
||||
@if ($newMetric && $statistics['totals'][$newMetric] != 0)
|
||||
<a href="{{ $newBadgeLink(0, $metric) }}"
|
||||
class="badge badge-pill badge-success ml-1 backoffice-statistics-click-badge"
|
||||
title="Neue im Monat anzeigen">+{{ number_format($statistics['totals'][$newMetric], 0, ',', '.') }}</a>
|
||||
|
|
@ -354,11 +354,13 @@
|
|||
{{ number_format((float) ($leaderAbos['total'] ?? 0), 0, ',', '.') }}
|
||||
</span>
|
||||
@if ($leaderAbos !== null)
|
||||
<div class="text-muted small">
|
||||
<div class="text-muted small"
|
||||
title="Jedes Abo zählt genau einmal: das Team einer Führungskraft reicht hier nur bis zur nächsten Führungskraft. Die Spalten in der Tabelle zeigen dagegen die komplette Downline und überschneiden sich — deshalb ist diese Summe kleiner.">
|
||||
{{ number_format((float) ($leaderAbos['total_own'] ?? 0), 0, ',', '.') }} eigene ·
|
||||
{{ number_format((float) ($leaderAbos['total_customer'] ?? 0), 0, ',', '.') }} Kunden ·
|
||||
{{ number_format((float) ($leaderAbos['total_team'] ?? 0), 0, ',', '.') }} Team ·
|
||||
{{ number_format((float) ($leaderAbos['total_team_customer'] ?? 0), 0, ',', '.') }} Teamkunden
|
||||
<span class="ion ion-md-information-circle-outline"></span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
|
@ -383,8 +385,6 @@
|
|||
<th>Führungskraft</th>
|
||||
<th class="text-right">Eigene Abos</th>
|
||||
<th class="text-right">Kundenabos</th>
|
||||
<th class="text-right" title="Berater-Abos im eigenen Team bis zur nächsten Führungskraft — diese Spalten überschneiden sich nicht und ergeben die Kopf-Summe.">Teamabos (direkt)</th>
|
||||
<th class="text-right" title="Kundenabos im eigenen Team bis zur nächsten Führungskraft.">Team-Kundenabos (direkt)</th>
|
||||
<th class="text-right" title="Berater-Abos der kompletten Downline — Teams untergeordneter Führungskräfte zählen hier mit, die Spalte lässt sich deshalb nicht aufsummieren.">Teamabos (gesamt)</th>
|
||||
<th class="text-right" title="Kundenabos der kompletten Downline — inklusive der Teams untergeordneter Führungskräfte.">Team-Kundenabos (gesamt)</th>
|
||||
<th class="text-right" title="Eigenes Abo + Kundenabos + Teamabos (gesamt) + Team-Kundenabos (gesamt)">Gesamt</th>
|
||||
|
|
@ -401,7 +401,10 @@
|
|||
<div class="font-weight-semibold">{{ $row['name'] }}</div>
|
||||
<div class="text-muted small">Linie {{ $row['line'] }} · {{ $row['career_level'] }}</div>
|
||||
</td>
|
||||
@foreach (['own_abo_count', 'customer_abo_count', 'team_abo_count', 'team_customer_abo_count', 'team_abo_count_total', 'team_customer_abo_count_total'] as $countKey)
|
||||
{{-- Die "direkt"-Spalten sind auf Kundenwunsch (07.09.2026) raus; die
|
||||
Kopf-Summe rechnet intern weiter mit ihnen, weil nur sie
|
||||
ueberschneidungsfrei ist (siehe Fussnote unter der Tabelle). --}}
|
||||
@foreach (['own_abo_count', 'customer_abo_count', 'team_abo_count_total', 'team_customer_abo_count_total'] as $countKey)
|
||||
<td class="text-right align-middle">
|
||||
@if (($row[$countKey] ?? 0) > 0)
|
||||
<span class="badge badge-pill badge-outline-default backoffice-statistics-click-badge">
|
||||
|
|
@ -422,9 +425,10 @@
|
|||
</div>
|
||||
<div class="card-body pt-2 pb-3">
|
||||
<p class="text-muted small mb-0">
|
||||
<strong>direkt</strong> = Team bis zur nächsten Führungskraft (überschneidungsfrei, ergibt die Kopf-Summe).
|
||||
<strong>gesamt</strong> = komplette Downline inklusive der Teams untergeordneter Führungskräfte — dieselben Abos
|
||||
können dort bei mehreren Führungskräften zählen, die Gesamt-Spalte lässt sich deshalb nicht aufsummieren.
|
||||
können dort bei mehreren Führungskräften zählen, die Spalten lassen sich deshalb nicht aufsummieren.
|
||||
Die <strong>Summe oben rechts</strong> zählt jedes Abo genau einmal (Team jeweils nur bis zur nächsten
|
||||
Führungskraft) und ist deshalb kleiner als die Spaltensummen.
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
|
|
@ -434,6 +438,8 @@
|
|||
|
||||
@php
|
||||
$recruiterRanking = $statistics['recruiter_ranking'] ?? null;
|
||||
// Aeltere Snapshots kennen die Summenzeile noch nicht -> dann bleibt sie weg.
|
||||
$recruiterTotals = $statistics['recruiter_totals'] ?? null;
|
||||
@endphp
|
||||
|
||||
<div class="row">
|
||||
|
|
@ -521,6 +527,18 @@
|
|||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
@if ($recruiterTotals !== null)
|
||||
{{-- Summe ueber die deduplizierte Neupartner-Menge, NICHT ueber die
|
||||
Spalte: dort steckt jeder Neupartner durch das Kettenvolumen
|
||||
mehrfach drin (Kundenwunsch 2026-09-07). --}}
|
||||
<tfoot>
|
||||
<tr class="font-weight-bold">
|
||||
<td>Summe</td>
|
||||
<td class="text-right">{{ number_format((float) $recruiterTotals['new_partner_count'], 0, ',', '.') }}</td>
|
||||
<td class="text-right">{{ \App\Services\Util::formatNumber($recruiterTotals['volume_points']) }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
@endif
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-body pt-2 pb-3">
|
||||
|
|
@ -528,6 +546,11 @@
|
|||
<strong>neues Volumen</strong> = Monats-Gesamtpunkte (Eigen- und Kundenpunkte, inkl. Starterpaket)
|
||||
der geworbenen Neupartner — plus rekursiv die Punkte der Neupartner, die diese im selben Monat
|
||||
selbst geworben haben. Klick auf eine Zeile zeigt, wer geworben wurde.
|
||||
@if ($recruiterTotals !== null)
|
||||
<br><strong>Summe</strong> = Volumen aller Neupartner des Monats, jeder genau einmal gezählt.
|
||||
Sie ist kleiner als die Spalte darüber, weil ein Neupartner dort zusätzlich im Kettenvolumen
|
||||
seines Sponsors steckt.
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
|
|
@ -625,6 +648,8 @@
|
|||
<th>Berater</th>
|
||||
<th class="text-right">Abos</th>
|
||||
<th class="text-right" title="Im gewählten Monat abgerechnete Kundenabo-Punkte">Abgerechnet (Punkte)</th>
|
||||
<th class="text-right" title="Davon aus den wiederkehrenden Abo-Positionen.">davon reine Abo-Punkte</th>
|
||||
<th class="text-right" title="Davon aus Artikeln, die einmalig zur Abo-Lieferung dazubestellt wurden. Erst ab 06/2026 erfasst — davor steht alles unter „reine Abo-Punkte".">einmalig dazu</th>
|
||||
<th class="text-right" title="Punkte der heute aktiven Kundenabos pro Lieferung">Bestand/Lieferung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -649,6 +674,14 @@
|
|||
<td class="text-right align-middle font-weight-bold">
|
||||
{{ \App\Services\Util::formatNumber($row['points_actual']) }}
|
||||
</td>
|
||||
{{-- Aufteilung erst ab 06/2026 erfasst; aeltere Snapshots haben die
|
||||
Schluessel gar nicht -> dann bleiben die Zellen leer. --}}
|
||||
<td class="text-right align-middle">
|
||||
{{ isset($row['points_recurring']) ? \App\Services\Util::formatNumber($row['points_recurring']) : '–' }}
|
||||
</td>
|
||||
<td class="text-right align-middle {{ ($row['points_addon'] ?? 0) > 0 ? 'text-success' : 'text-muted' }}">
|
||||
{{ isset($row['points_addon']) ? \App\Services\Util::formatNumber($row['points_addon']) : '–' }}
|
||||
</td>
|
||||
<td class="text-right align-middle">
|
||||
{{ \App\Services\Util::formatNumber($row['points_target']) }}
|
||||
</td>
|
||||
|
|
|
|||
162
tests/Feature/BackofficeSnapshotFreezeTest.php
Normal file
162
tests/Feature/BackofficeSnapshotFreezeTest.php
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
<?php
|
||||
|
||||
use App\Models\UserAbo;
|
||||
use App\Models\UserSalesVolume;
|
||||
use App\Services\Backoffice\BackofficeDashboardService;
|
||||
use App\Services\Backoffice\BackofficeDrilldownService;
|
||||
use App\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
// September laeuft, August ist abgeschlossen.
|
||||
Carbon::setTestNow('2026-09-15 10:00:00');
|
||||
});
|
||||
|
||||
afterEach(fn () => Carbon::setTestNow());
|
||||
|
||||
function freezeUser(?int $sponsor = null): User
|
||||
{
|
||||
return User::forceCreate([
|
||||
'email' => 'freeze-'.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 freezeVolume(User $user, float $points, int $month = 8): UserSalesVolume
|
||||
{
|
||||
return UserSalesVolume::forceCreate([
|
||||
'user_id' => $user->id,
|
||||
'month' => $month,
|
||||
'year' => 2026,
|
||||
'date' => '2026-0'.$month.'-05',
|
||||
'points' => $points,
|
||||
'total_net' => $points,
|
||||
'status' => 1,
|
||||
'status_points' => 1,
|
||||
'status_turnover' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
function freezeAbo(User $member, string $isFor = 'ot', string $startDate = '2026-08-05'): UserAbo
|
||||
{
|
||||
return UserAbo::create([
|
||||
'user_id' => $member->id,
|
||||
'member_id' => $member->id,
|
||||
'shopping_user_id' => 0,
|
||||
'email' => 'a-'.uniqid('', true).'@example.test',
|
||||
'is_for' => $isFor,
|
||||
'payone_userid' => 0,
|
||||
'clearingtype' => 'cc',
|
||||
'amount' => 5000,
|
||||
'active' => true,
|
||||
'status' => 2,
|
||||
'abo_interval' => 5,
|
||||
'start_date' => $startDate,
|
||||
'next_date' => '2026-10-05',
|
||||
]);
|
||||
}
|
||||
|
||||
it('haelt Punkte eines abgeschlossenen Monats nach einer spaeteren Korrektur fest', function () {
|
||||
$root = freezeUser();
|
||||
$anna = freezeUser($root->id);
|
||||
$volume = freezeVolume($anna, 100);
|
||||
|
||||
app(BackofficeDashboardService::class)->storeSnapshot($root, 8, 2026);
|
||||
|
||||
$volume->update(['points' => 500]);
|
||||
|
||||
$overview = app(BackofficeDashboardService::class)->overview($root, 8, 2026);
|
||||
|
||||
expect($overview['_meta']['source'])->toBe('snapshot')
|
||||
->and((float) $overview['totals']['total_points'])->toBe(100.0);
|
||||
});
|
||||
|
||||
it('haelt auch die Abo-Bestaende und Ranglisten fest', function () {
|
||||
$root = freezeUser();
|
||||
$anna = freezeUser($root->id);
|
||||
$abo = freezeAbo($anna);
|
||||
|
||||
app(BackofficeDashboardService::class)->storeSnapshot($root, 8, 2026);
|
||||
|
||||
// Kuendigung im September darf den August nicht mehr veraendern.
|
||||
$abo->update(['active' => false, 'status' => 4, 'cancel_date' => '2026-09-10']);
|
||||
|
||||
$overview = app(BackofficeDashboardService::class)->overview($root, 8, 2026);
|
||||
|
||||
expect((int) $overview['totals']['team_customer_abos'])->toBe(1);
|
||||
});
|
||||
|
||||
it('laesst einen spaeter dazugekommenen Teampartner aus dem abgeschlossenen Monat heraus', function () {
|
||||
$root = freezeUser();
|
||||
$anna = freezeUser($root->id);
|
||||
freezeVolume($anna, 100);
|
||||
|
||||
app(BackofficeDashboardService::class)->storeSnapshot($root, 8, 2026);
|
||||
|
||||
// Neuer Teampartner im September, mit einer Buchung, die im August liegt
|
||||
// (z. B. nachtraeglich zugeordnet) — der abgeschlossene Monat bleibt unberuehrt.
|
||||
$spaeter = freezeUser($root->id);
|
||||
freezeVolume($spaeter, 999);
|
||||
app(BackofficeDashboardService::class)->flushLineBucketsMemo($root->id);
|
||||
|
||||
$overview = app(BackofficeDashboardService::class)->overview($root, 8, 2026);
|
||||
$details = app(BackofficeDrilldownService::class)->details($root, 0, 'total_points', 8, 2026);
|
||||
|
||||
expect((float) $overview['totals']['total_points'])->toBe(100.0)
|
||||
->and($details['summary']['total_points'])->toBe(100.0)
|
||||
->and($details['rows'])->toHaveCount(1)
|
||||
->and($details['rows'][0]['user_id'])->toBe($anna->id);
|
||||
});
|
||||
|
||||
it('zeigt in der Abo-Detailliste genau die eingefrorenen Abos', function () {
|
||||
$root = freezeUser();
|
||||
$anna = freezeUser($root->id);
|
||||
$abo = freezeAbo($anna);
|
||||
|
||||
app(BackofficeDashboardService::class)->storeSnapshot($root, 8, 2026);
|
||||
|
||||
$abo->update(['active' => false, 'status' => 4, 'cancel_date' => '2026-09-10']);
|
||||
$abo->delete();
|
||||
|
||||
$details = app(BackofficeDrilldownService::class)->details($root, 0, 'team_customer_abos', 8, 2026);
|
||||
|
||||
expect($details['summary']['abo_count'])->toBe(1);
|
||||
});
|
||||
|
||||
it('haelt Kachel und verlinkte Liste der neuen Abos zusammen', function () {
|
||||
$root = freezeUser();
|
||||
$anna = freezeUser($root->id);
|
||||
$abo = freezeAbo($anna, 'ot', '2026-08-20'); // neu im August
|
||||
|
||||
app(BackofficeDashboardService::class)->storeSnapshot($root, 8, 2026);
|
||||
|
||||
$abo->update(['active' => false, 'status' => 4, 'cancel_date' => '2026-09-08']);
|
||||
|
||||
$overview = app(BackofficeDashboardService::class)->overview($root, 8, 2026);
|
||||
$details = app(BackofficeDrilldownService::class)->details($root, 0, 'team_customer_abos', 8, 2026, true);
|
||||
|
||||
expect((int) $overview['totals']['team_customer_abos_new'])->toBe(1)
|
||||
->and($details['summary']['abo_count'])->toBe(1);
|
||||
});
|
||||
|
||||
it('rechnet den laufenden Monat weiterhin live', function () {
|
||||
$root = freezeUser();
|
||||
$anna = freezeUser($root->id);
|
||||
$volume = freezeVolume($anna, 100, 9);
|
||||
|
||||
$volume->update(['points' => 500]);
|
||||
|
||||
$overview = app(BackofficeDashboardService::class)->overview($root, 9, 2026);
|
||||
|
||||
expect($overview['_meta']['source'])->toBe('live')
|
||||
->and((float) $overview['totals']['total_points'])->toBe(500.0);
|
||||
});
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
use App\Models\Product;
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\ShoppingOrderItem;
|
||||
use App\Models\UserAbo;
|
||||
use App\Models\UserAboItem;
|
||||
use App\Models\UserLevel;
|
||||
|
|
@ -251,7 +252,11 @@ it('liefert Top-10-Kundenabo-Ranglisten nach Anzahl und Umsatz mit Soll und Ist'
|
|||
->and($top['by_points'][1]['points_actual'])->toBe(0.0);
|
||||
});
|
||||
|
||||
it('ueberlagert Snapshot-Monate mit tagesaktuellen Abo-Bestandszahlen', function (): void {
|
||||
it('haelt die Abo-Bestaende eines abgeschlossenen Monats fest', function (): void {
|
||||
// Kundenentscheidung 2026-09-10: Ein abgeschlossener Monat ist abgerechnet und
|
||||
// wird nicht mehr aus heutigen Daten nachgerechnet. Vorher wurden die
|
||||
// Bestandszahlen hier bewusst live ueberlagert ("Abo-Bestaende tagesaktuell");
|
||||
// dieser Test hielt die alte Regel fest und beschreibt jetzt die neue.
|
||||
$root = bslMakeUser();
|
||||
$anna = bslMakeUser($root->id);
|
||||
|
||||
|
|
@ -267,15 +272,18 @@ it('ueberlagert Snapshot-Monate mit tagesaktuellen Abo-Bestandszahlen', function
|
|||
$overview = $service->overview($root, 5, 2026);
|
||||
|
||||
expect($overview['_meta']['source'])->toBe('snapshot')
|
||||
->and($overview['abo_stock_live'])->toBeTrue()
|
||||
->and($overview['lines'][1]['team_partner_abos'])->toBe(0) // Bestand: live
|
||||
->and($overview['lines'][1]['team_customer_abos'])->toBe(1) // Bestand: live
|
||||
->and($overview['lines'][1]['team_partner_abos_new'])->toBe(1) // "+N neu" im Mai: eingefroren
|
||||
->and($overview['totals']['team_partner_abos'])->toBe(0)
|
||||
->and($overview['totals']['team_customer_abos'])->toBe(1);
|
||||
->and($overview)->not->toHaveKey('abo_stock_live')
|
||||
->and($overview['lines'][1]['team_partner_abos'])->toBe(1) // Stand Mai
|
||||
->and($overview['lines'][1]['team_customer_abos'])->toBe(0) // Stand Mai
|
||||
->and($overview['lines'][1]['team_partner_abos_new'])->toBe(1)
|
||||
->and($overview['totals']['team_partner_abos'])->toBe(1)
|
||||
->and($overview['totals']['team_customer_abos'])->toBe(0);
|
||||
});
|
||||
|
||||
it('ergaenzt beim Snapshot-Overlay Linien, die erst nach dem Snapshot entstanden sind', function (): void {
|
||||
it('nimmt spaeter entstandene Linien nicht mehr in einen abgeschlossenen Monat auf', function (): void {
|
||||
// Genau der Fall, der die Entscheidung ausgeloest hat: Teampartner koennen
|
||||
// dazukommen oder verschoben werden. Ein abgerechneter Monat darf sich dadurch
|
||||
// nicht mehr aendern.
|
||||
$root = bslMakeUser();
|
||||
$anna = bslMakeUser($root->id);
|
||||
|
||||
|
|
@ -294,12 +302,9 @@ it('ergaenzt beim Snapshot-Overlay Linien, die erst nach dem Snapshot entstanden
|
|||
$overview = $service->overview($root, 5, 2026);
|
||||
|
||||
expect($overview['_meta']['source'])->toBe('snapshot')
|
||||
->and($overview['lines'])->toHaveKey(2)
|
||||
->and($overview['lines'][2]['team_partner_abos'])->toBe(1)
|
||||
->and($overview['lines'][2]['team_customer_abos'])->toBe(1)
|
||||
->and($overview['lines'][2]['team_partner_abos_new'])->toBe(0) // Monatswert: im Snapshot-Monat gab es die Linie nicht
|
||||
->and($overview['totals']['team_partner_abos'])->toBe(2)
|
||||
->and($overview['totals']['team_customer_abos'])->toBe(1);
|
||||
->and($overview['lines'])->not->toHaveKey(2)
|
||||
->and($overview['totals']['team_partner_abos'])->toBe(1)
|
||||
->and($overview['totals']['team_customer_abos'])->toBe(0);
|
||||
});
|
||||
|
||||
it('nimmt den Statistik-Nutzer selbst in die Kundenabo-Ranglisten auf', function (): void {
|
||||
|
|
@ -499,7 +504,8 @@ it('summiert das Recruiter-Volumen aus Eigen- und Kundenpunkten inklusive Ketten
|
|||
'status' => 1,
|
||||
]);
|
||||
|
||||
$ranking = collect(app(BackofficeDashboardService::class)->overview($root, 6, 2026)['recruiter_ranking'])->keyBy('user_id');
|
||||
$overview = app(BackofficeDashboardService::class)->overview($root, 6, 2026);
|
||||
$ranking = collect($overview['recruiter_ranking'])->keyBy('user_id');
|
||||
|
||||
// Root: Annas 500 (400 eigene + 100 Kunden) + rekursiv Bens 200
|
||||
expect($ranking->get($root->id)['volume_points'])->toBe(700.0)
|
||||
|
|
@ -507,6 +513,14 @@ it('summiert das Recruiter-Volumen aus Eigen- und Kundenpunkten inklusive Ketten
|
|||
->and($ranking->get($root->id)['partners'][0]['children'][0]['total_points'])->toBe(200.0)
|
||||
// Anna selbst: nur Bens Punkte
|
||||
->and($ranking->get($anna->id)['volume_points'])->toBe(200.0);
|
||||
|
||||
// Summenzeile: jeder Neupartner genau EINMAL. Die Spaltensumme waere
|
||||
// 700 + 200 = 900, weil Ben im Kettenvolumen von Root steckt und zusaetzlich
|
||||
// in Annas Zeile. Richtig sind Annas 500 + Bens 200 = 700 bei 2 Neupartnern.
|
||||
expect($overview['recruiter_totals'])->toBe([
|
||||
'new_partner_count' => 2,
|
||||
'volume_points' => 700.0,
|
||||
]);
|
||||
});
|
||||
|
||||
it('liefert im Neupartner-Drilldown zusaetzlich die Spalte Punkte gesamt', function (): void {
|
||||
|
|
@ -536,3 +550,76 @@ it('liefert im Neupartner-Drilldown zusaetzlich die Spalte Punkte gesamt', funct
|
|||
->and($details['summary']['customer_points'])->toBe(250.0)
|
||||
->and($details['summary']['total_points'])->toBe(350.0);
|
||||
});
|
||||
|
||||
it('teilt die abgerechneten Kundenabo-Punkte in wiederkehrende und einmalig dazubestellte auf', function (): void {
|
||||
// Kundenwunsch 2026-09-07: "1231 abgerechnet - davon 1000 reine Abo-Punkte -
|
||||
// 231 einmalige Bestellungen dazu."
|
||||
$root = bslMakeUser();
|
||||
$anna = bslMakeUser($root->id);
|
||||
|
||||
$order = ShoppingOrder::forceCreate([
|
||||
'shopping_user_id' => 0,
|
||||
'auth_user_id' => $anna->id,
|
||||
'country_id' => 0,
|
||||
'user_shop_id' => 0,
|
||||
'is_abo' => true,
|
||||
'paid' => true,
|
||||
]);
|
||||
|
||||
// 2 x 500 wiederkehrend + 1 x 231 einmalig dazu = 1231
|
||||
ShoppingOrderItem::forceCreate([
|
||||
'shopping_order_id' => $order->id, 'product_id' => 1, 'qty' => 2, 'points' => 500, 'is_abo_addon' => false,
|
||||
]);
|
||||
ShoppingOrderItem::forceCreate([
|
||||
'shopping_order_id' => $order->id, 'product_id' => 2, 'qty' => 1, 'points' => 231, 'is_abo_addon' => true,
|
||||
]);
|
||||
|
||||
UserSalesVolume::forceCreate([
|
||||
'user_id' => $anna->id,
|
||||
'shopping_order_id' => $order->id,
|
||||
'month' => 6,
|
||||
'year' => 2026,
|
||||
'points' => 1231,
|
||||
'status' => 2,
|
||||
]);
|
||||
|
||||
$row = collect(app(BackofficeDashboardService::class)->overview($root, 6, 2026)['top_customer_abos']['by_points'])
|
||||
->firstWhere('user_id', $anna->id);
|
||||
|
||||
expect($row['points_actual'])->toBe(1231.0)
|
||||
->and($row['points_recurring'])->toBe(1000.0)
|
||||
->and($row['points_addon'])->toBe(231.0)
|
||||
// Die beiden Spalten muessen die angezeigte Gesamtsumme ergeben.
|
||||
->and($row['points_recurring'] + $row['points_addon'])->toBe($row['points_actual']);
|
||||
});
|
||||
|
||||
it('zaehlt Abo-Bestellungen ohne Einmal-Positionen komplett als wiederkehrend', function (): void {
|
||||
// Bestellungen vor 06/2026 kennen is_abo_addon nicht (Default false) —
|
||||
// die Aufteilung darf dort nichts erfinden.
|
||||
$root = bslMakeUser();
|
||||
$anna = bslMakeUser($root->id);
|
||||
|
||||
$order = ShoppingOrder::forceCreate([
|
||||
'shopping_user_id' => 0,
|
||||
'auth_user_id' => $anna->id,
|
||||
'country_id' => 0,
|
||||
'user_shop_id' => 0,
|
||||
'is_abo' => true,
|
||||
'paid' => true,
|
||||
]);
|
||||
UserSalesVolume::forceCreate([
|
||||
'user_id' => $anna->id,
|
||||
'shopping_order_id' => $order->id,
|
||||
'month' => 6,
|
||||
'year' => 2026,
|
||||
'points' => 400,
|
||||
'status' => 2,
|
||||
]);
|
||||
|
||||
$row = collect(app(BackofficeDashboardService::class)->overview($root, 6, 2026)['top_customer_abos']['by_points'])
|
||||
->firstWhere('user_id', $anna->id);
|
||||
|
||||
expect($row['points_actual'])->toBe(400.0)
|
||||
->and($row['points_recurring'])->toBe(400.0)
|
||||
->and($row['points_addon'])->toBe(0.0);
|
||||
});
|
||||
|
|
|
|||
131
tests/Feature/CustomerAboAddonSplitTest.php
Normal file
131
tests/Feature/CustomerAboAddonSplitTest.php
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
<?php
|
||||
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\ShoppingOrderItem;
|
||||
use App\Models\UserSalesVolume;
|
||||
use App\Services\Backoffice\BackofficeDashboardService;
|
||||
use App\Services\BusinessPlan\SalesPointsVolume;
|
||||
use App\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
beforeEach(fn () => Carbon::setTestNow('2026-09-10 12:00:00'));
|
||||
afterEach(fn () => Carbon::setTestNow());
|
||||
|
||||
function addonUser(?int $sponsor = null): User
|
||||
{
|
||||
return User::forceCreate([
|
||||
'email' => 'addon-'.uniqid().'@example.test',
|
||||
'password' => bcrypt('secret'),
|
||||
'lang' => 'de',
|
||||
'admin' => 0,
|
||||
'm_level' => 1,
|
||||
'm_sponsor' => $sponsor,
|
||||
'active_date' => '2026-01-01',
|
||||
'payment_account' => '2027-01-01',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Abo-Bestellung mit optionalem Einmal-Zusatz und der zugehoerigen Umsatzbuchung.
|
||||
*/
|
||||
function addonOrder(User $member, float $points, float $addonPoints = 0.0): ShoppingOrder
|
||||
{
|
||||
$order = ShoppingOrder::create([
|
||||
'shopping_user_id' => 0,
|
||||
'auth_user_id' => $member->id,
|
||||
'member_id' => $member->id,
|
||||
'country_id' => 1,
|
||||
'language' => 'de',
|
||||
'user_shop_id' => 1,
|
||||
'payment_for' => 6,
|
||||
'total' => $points,
|
||||
'subtotal' => $points,
|
||||
'points' => $points,
|
||||
'paid' => true,
|
||||
'shipped' => 0,
|
||||
'is_abo' => true,
|
||||
'mode' => 'test',
|
||||
]);
|
||||
|
||||
if ($addonPoints > 0) {
|
||||
ShoppingOrderItem::forceCreate([
|
||||
'shopping_order_id' => $order->id,
|
||||
'product_id' => 0,
|
||||
'qty' => 1,
|
||||
'price' => $addonPoints,
|
||||
'points' => $addonPoints,
|
||||
'is_abo_addon' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
function addonVolume(User $member, float $points, ShoppingOrder $order): UserSalesVolume
|
||||
{
|
||||
return UserSalesVolume::forceCreate([
|
||||
'user_id' => $member->id,
|
||||
'shopping_order_id' => $order->id,
|
||||
'month' => 9,
|
||||
'year' => 2026,
|
||||
'date' => '2026-09-01',
|
||||
'status' => 2,
|
||||
'status_points' => 1,
|
||||
'status_turnover' => null,
|
||||
'points' => $points,
|
||||
'total_net' => $points,
|
||||
]);
|
||||
}
|
||||
|
||||
function addonRankingRow(User $root): array
|
||||
{
|
||||
return app(BackofficeDashboardService::class)->overview($root, 9, 2026)['top_customer_abos']['by_points'][0];
|
||||
}
|
||||
|
||||
it('weist die Zusatzpunkte einer stornierten Bestellung nicht mehr aus', function () {
|
||||
$root = addonUser();
|
||||
$member = addonUser($root->id);
|
||||
|
||||
$storniert = addonOrder($member, 100.0, 20.0);
|
||||
SalesPointsVolume::cancelSalesPointsVolume(addonVolume($member, 100.0, $storniert), 9911);
|
||||
|
||||
addonVolume($member, 100.0, addonOrder($member, 100.0));
|
||||
|
||||
$row = addonRankingRow($root);
|
||||
|
||||
expect($row['points_actual'])->toBe(100.0)
|
||||
->and($row['points_addon'])->toBe(0.0)
|
||||
->and($row['points_recurring'])->toBe(100.0);
|
||||
});
|
||||
|
||||
it('weist die Zusatzpunkte einer nicht stornierten Bestellung unveraendert aus', function () {
|
||||
$root = addonUser();
|
||||
$member = addonUser($root->id);
|
||||
|
||||
addonVolume($member, 100.0, addonOrder($member, 100.0, 20.0));
|
||||
|
||||
$row = addonRankingRow($root);
|
||||
|
||||
expect($row['points_actual'])->toBe(100.0)
|
||||
->and($row['points_addon'])->toBe(20.0)
|
||||
->and($row['points_recurring'])->toBe(80.0);
|
||||
});
|
||||
|
||||
it('rechnet einen Teilstorno anteilig auf die Zusatzpunkte an', function () {
|
||||
$root = addonUser();
|
||||
$member = addonUser($root->id);
|
||||
|
||||
$order = addonOrder($member, 100.0, 20.0);
|
||||
addonVolume($member, 100.0, $order);
|
||||
addonVolume($member, -50.0, $order)->update(['status' => 6, 'status_turnover' => 2, 'storno_origin_status' => 2]);
|
||||
|
||||
$row = addonRankingRow($root);
|
||||
|
||||
expect($row['points_actual'])->toBe(50.0)
|
||||
->and($row['points_addon'])->toBe(10.0)
|
||||
->and($row['points_recurring'])->toBe(40.0);
|
||||
});
|
||||
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);
|
||||
});
|
||||
|
|
@ -22,6 +22,7 @@ beforeEach(function () {
|
|||
Schema::dropIfExists('user_abos');
|
||||
Schema::dropIfExists('payment_transactions');
|
||||
Schema::dropIfExists('shopping_payments');
|
||||
Schema::dropIfExists('shopping_order_items');
|
||||
Schema::dropIfExists('shopping_orders');
|
||||
Schema::dropIfExists('users');
|
||||
Schema::dropIfExists('user_levels');
|
||||
|
|
@ -104,6 +105,19 @@ beforeEach(function () {
|
|||
$table->softDeletes();
|
||||
});
|
||||
|
||||
// Positionsebene: nur fuer die Aufteilung der Abo-Punkte in wiederkehrende
|
||||
// und einmalig dazubestellte (is_abo_addon) noetig.
|
||||
Schema::create('shopping_order_items', function ($table) {
|
||||
$table->increments('id');
|
||||
$table->unsignedInteger('shopping_order_id');
|
||||
$table->unsignedInteger('product_id')->nullable();
|
||||
$table->unsignedInteger('qty')->default(1);
|
||||
$table->decimal('points', 10, 2)->nullable();
|
||||
$table->boolean('is_abo_addon')->default(false);
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
|
||||
Schema::create('shopping_payments', function ($table) {
|
||||
$table->increments('id');
|
||||
$table->unsignedInteger('shopping_order_id');
|
||||
|
|
@ -134,6 +148,8 @@ beforeEach(function () {
|
|||
$table->decimal('total_net', 13, 2)->nullable();
|
||||
$table->unsignedTinyInteger('status')->nullable();
|
||||
$table->unsignedTinyInteger('status_turnover')->nullable();
|
||||
// Ursprungsart einer Stornobuchung — gebraucht von den Eigen-/Shop-Praedikaten.
|
||||
$table->unsignedTinyInteger('storno_origin_status')->nullable();
|
||||
// Nur-KP-Kennzeichen (Neupartner-Einschreibungspunkte) — gebraucht von
|
||||
// SalesVolumePoints::nonPaylinePoints().
|
||||
$table->unsignedTinyInteger('status_points')->nullable();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue