mivita/app/Services/Backoffice/BackofficeDrilldownService.php
Kevin 8d7a98c685 Statistik: abgeschlossene Monate werden vollstaendig eingefroren
Am ersten des Folgemonats wird der Monat abgerechnet, die Provision berechnet
und Gutschriften werden ausgestellt. Deswegen fliessen auch Stornos nicht mehr
rueckwirkend ein. Genau dieser Stand muss eingefroren werden — und zwar
einschliesslich Teamstruktur und Detaillisten, nicht nur der Kennzahlen.

Bisher hat `overlayCurrentAboStock()` beim Lesen eines gespeicherten Monats die
gesamte Kundenabo-Topliste live neu gerechnet, also auch abgerechnete
Monatswerte. Und die Detaillisten hinter den Kacheln lasen Monatsbuchungen und
Teamzuordnung grundsaetzlich neu — nach einem Sponsorwechsel oder einer
Loeschung konnte derselbe historische Klick andere Ergebnisse liefern als die
Kachel darueber.

Das Overlay entfaellt; der Snapshot wird unveraendert zurueckgegeben. Die
Detaillisten lesen die eingefrorene Mitgliedermenge aus dem Snapshot
(`user_ids` lag dort bereits, wurde nur nie benutzt) und die eingefrorenen
Abo-Ids aus dem neuen Feld `abo_ids`. Payload-Version 2.

Grenze, die bleibt: Ein Rebuild vergangener Monate friert die Struktur des
AUSFUEHRUNGSTAGS ein, nicht die des damaligen Monatsendes — frueheres
Sponsoring wird nirgends historisiert. Der `--force`-Lauf ist deshalb nur
richtig, wenn man bewusst einen einheitlichen Stand herstellt. Das ist hier der
Fall (Schemawechsel des Payloads), fuer den laufenden Betrieb genuegt der
naechtliche Lauf ohne `--force`.

Loest die August-Entscheidung F7 ("Abo-Bestaende tagesaktuell") ab. Zwei Tests
hielten die alte Regel fest und beschreiben jetzt die neue.

Mit drin, weil dieselben Dateien betroffen sind:
- Negative Monatssalden verschwanden aus dem Drilldown (`HAVING > 0`), obwohl
  ihre Betraege in der Uebersicht steckten. Nur die 1000-Punkte-Liste behaelt
  ihren Schwellenfilter (A03).
- Zusatzpunkte einer stornierten Bestellung werden anteilig verrechnet statt
  voll abgezogen (A06).
- Unformatierte Sortierwerte fuer die Detailliste (A14, siehe Folgecommit).

Audit-Befunde A03, A04, A06, A12.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 11:19:06 +00:00

564 lines
23 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Services\Backoffice;
use App\Models\BackofficeStatisticsSnapshot;
use App\Models\UserAbo;
use App\Models\UserSalesVolume;
use App\User;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
class BackofficeDrilldownService
{
public function __construct(private BackofficeDashboardService $dashboardService) {}
/**
* @return array{metric: string, metric_label: string, only_new: bool, line: int, line_label: string, month: int, year: int, rows: array<int, array<string, mixed>>, summary: array<string, mixed>}
*/
public function details(User $viewer, int $line, string $metric, int $month, int $year, bool $onlyNew = false): array
{
$metricLabels = $this->dashboardService->metricLabels();
if (! array_key_exists($metric, $metricLabels)) {
abort(404);
}
$onlyNew = $onlyNew && in_array($metric, ['team_partner_abos', 'team_customer_abos'], true);
// 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, $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 => [],
};
if ($metric === 'shop_1000') {
$rows = $this->markViewerRow($rows, (int) $viewer->id);
}
return [
'metric' => $metric,
'metric_label' => $metricLabels[$metric].($onlyNew ? ' – nur neue' : ''),
'only_new' => $onlyNew,
'line' => $line,
'line_label' => $line > 0 ? 'Linie '.$line : 'Alle Linien',
'month' => $month,
'year' => $year,
'rows' => $rows,
'summary' => $this->summary($rows),
];
}
/**
* Kennzeichnet die eigene Zeile des Betrachters (der VIP zählt beim
* 1000-Punkte-Shop selbst mit und soll in der Liste erkennbar sein).
*
* @param array<int, array<string, mixed>> $rows
* @return array<int, array<string, mixed>>
*/
private function markViewerRow(array $rows, int $viewerId): array
{
return array_map(function (array $row) use ($viewerId): array {
if ((int) ($row['user_id'] ?? 0) === $viewerId) {
$row['name'] .= ' (ich)';
}
return $row;
}, $rows);
}
/**
* @param array<int, array<string, mixed>> $rows
* @return array{count: int, abo_count: int, points: float, customer_points: float, own_points: float, external_points: float, customer_abo_points: float, customer_single_order_points: float, customer_other_points: float, total_points: float, deliveries: int}
*/
private function summary(array $rows): array
{
return [
'count' => count($rows),
'abo_count' => (int) collect($rows)->sum('abo_count'),
'points' => (float) collect($rows)->sum('points'),
'customer_points' => (float) collect($rows)->sum('customer_points'),
'own_points' => (float) collect($rows)->sum('own_points'),
'external_points' => (float) collect($rows)->sum('external_points'),
'customer_abo_points' => (float) collect($rows)->sum('customer_abo_points'),
'customer_single_order_points' => (float) collect($rows)->sum('customer_single_order_points'),
'customer_other_points' => (float) collect($rows)->sum('customer_other_points'),
'total_points' => (float) collect($rows)->sum('total_points'),
'deliveries' => (int) collect($rows)->sum('deliveries'),
];
}
/**
* @param int[] $userIds
* @return array<int, array<string, mixed>>
*/
private function consultantRows(array $userIds): array
{
return User::query()
->with(['account', 'user_level'])
->whereIn('id', $userIds)
->whereNotNull('m_level')
->whereNotNull('payment_account')
->orderBy('id')
->get()
->map(fn (User $user) => [
'type' => 'user',
'user_id' => $user->id,
'name' => $this->userName($user),
'email' => $user->email,
'career_level' => $this->careerLevel($user),
'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();
}
/**
* @param int[] $userIds
* @return array<int, array<string, mixed>>
*/
private function newPartnerRows(array $userIds, int $month, int $year): array
{
$partners = User::query()
->with(['account', 'user_level'])
->whereIn('id', $userIds)
->newPartnerIn($month, $year)
->orderBy('active_date')
->get();
$sponsors = User::query()
->with('account')
->whereIn('id', $partners->pluck('m_sponsor')->filter()->unique())
->get()
->keyBy('id');
$monthlyPoints = $this->monthlyPointsByUser($partners->pluck('id')->map(fn ($id) => (int) $id)->all(), $month, $year);
return $partners
->map(fn (User $user) => [
'type' => 'user',
'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();
}
/**
* Externe Kundenpunkte (Shop + Abo) sowie Gesamtpunkte (eigene + Kundenpunkte,
* inkl. Starterpaket Status 1) je User im gewählten Monat — tagesaktuell
* aus user_sales_volumes summiert (gleiche Status-Logik wie die Übersicht).
*
* @param int[] $userIds
* @return Collection<int, array{external_points: float, total_points: float}>
*/
private function monthlyPointsByUser(array $userIds, int $month, int $year): Collection
{
if ($userIds === []) {
return collect();
}
return UserSalesVolume::query()
->whereIn('user_id', $userIds)
->where('month', $month)
->where('year', $year)
->select('user_id')
->selectRaw(SalesVolumePoints::externalPoints().' as external_points')
->selectRaw(SalesVolumePoints::totalPoints().' as total_points')
->groupBy('user_id')
->get()
->keyBy('user_id')
->map(fn ($row) => [
'external_points' => (float) $row->external_points,
'total_points' => (float) $row->total_points,
]);
}
/**
* 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 $frozenAboIds = null): array
{
return $this->aboScope($frozenAboIds)
->with(['user.account', 'user.user_level', 'user_abo_items.product', 'user_abo_orders.shopping_order.shopping_payments.payment_transactions'])
->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) => [
'type' => 'abo',
'abo_id' => $abo->id,
'user_id' => $abo->user_id,
'name' => $abo->user ? $this->userName($abo->user) : '#'.$abo->user_id,
'email' => $abo->user?->email,
'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->getRawOriginal('next_date')),
'next_date_sort' => $this->sortDate($abo->getRawOriginal('next_date')),
'deliveries' => $abo->getCountOrders(),
'status' => $abo->status,
'status_label' => $abo->getStatusType(),
'status_badge' => $abo->getStatusFormated(),
'status_reason' => $this->aboStatusReason($abo),
])
->values()
->all();
}
/**
* Teamkundenabos pro Teampartner (member_id) gruppiert: je Teampartner die
* Anzahl der Kundenabos, die Gesamt-Volumenpunkte sowie die einzelnen Abos
* mit Punkten je Abo. Kundennamen bleiben aus Datenschutzgründen außen vor.
*
* @param int[] $userIds
* @return array<int, array<string, mixed>>
*/
private function customerAboRows(array $userIds, int $month, int $year, bool $onlyNew = false, ?array $frozenAboIds = null): array
{
return $this->aboScope($frozenAboIds)
->with(['member.account', 'member.user_level', 'user_abo_items.product'])
->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()
->groupBy(fn (UserAbo $abo) => (int) $abo->member_id)
->map(function ($abos) use ($month, $year) {
$consultant = $abos->first()->member;
$memberId = (int) $abos->first()->member_id;
$aboItems = $abos->map(fn (UserAbo $abo) => [
'abo_id' => $abo->id,
'points' => (float) $abo->getTotalPoints(),
'start_date' => $this->formatDate($abo->getRawOriginal('start_date')),
'next_date' => $this->formatDate($abo->getRawOriginal('next_date')),
'is_new_this_month' => $this->isAboNewInMonth($abo, $month, $year),
])->values()->all();
return [
'type' => 'customer_abo_group',
'consultant_id' => $memberId,
'name' => $consultant ? $this->userName($consultant) : '#'.$memberId,
'email' => $consultant?->email,
'career_level' => $consultant ? $this->careerLevel($consultant) : '-',
'abo_count' => $abos->count(),
'points' => (float) $abos->sum(fn (UserAbo $abo) => $abo->getTotalPoints()),
'abos' => $aboItems,
];
})
->sortByDesc('points')
->values()
->all();
}
/**
* @param int[] $userIds
* @return array<int, array<string, mixed>>
*/
private function pointsRows(array $userIds, int $month, int $year, string $metric): array
{
$rows = UserSalesVolume::query()
->leftJoin('shopping_orders', 'shopping_orders.id', '=', 'user_sales_volumes.shopping_order_id')
->whereIn('user_id', $userIds)
->where('month', $month)
->where('year', $year)
->select('user_sales_volumes.user_id')
->selectRaw(SalesVolumePoints::ownPoints().' as own_points')
->selectRaw(SalesVolumePoints::externalPoints().' as external_points')
->selectRaw(SalesVolumePoints::customerAboPoints().' as customer_abo_points')
->selectRaw(SalesVolumePoints::customerSingleOrderPoints().' as customer_single_order_points')
->selectRaw(SalesVolumePoints::customerOtherPoints().' as customer_other_points')
->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');
}
if ($metric === 'external_points') {
$rows->havingRaw(SalesVolumePoints::externalPoints().' <> 0');
}
if ($metric === 'customer_abo_points') {
$rows->havingRaw(SalesVolumePoints::customerAboPoints().' <> 0');
}
if ($metric === 'customer_single_order_points') {
$rows->havingRaw(SalesVolumePoints::customerSingleOrderPoints().' <> 0');
}
if ($metric === 'customer_other_points') {
$rows->havingRaw(SalesVolumePoints::customerOtherPoints().' <> 0');
}
if ($metric === 'total_points') {
$rows->havingRaw(SalesVolumePoints::totalPoints().' <> 0');
}
if ($metric === 'shop_1000') {
$rows->havingRaw(SalesVolumePoints::totalPoints().' >= 1000');
}
$salesRows = $rows->orderByDesc('total_points')->get();
$users = User::query()->with(['account', 'user_level'])->whereIn('id', $salesRows->pluck('user_id'))->get()->keyBy('id');
return $salesRows
->map(fn (UserSalesVolume $row) => [
'type' => 'points',
'user_id' => $row->user_id,
'name' => $users->has($row->user_id) ? $this->userName($users->get($row->user_id)) : '#'.$row->user_id,
'email' => $users->get($row->user_id)?->email,
'career_level' => $users->has($row->user_id) ? $this->careerLevel($users->get($row->user_id)) : '-',
'own_points' => (float) $row->own_points,
'external_points' => (float) $row->external_points,
'customer_abo_points' => (float) $row->customer_abo_points,
'customer_single_order_points' => (float) $row->customer_single_order_points,
'customer_other_points' => (float) $row->customer_other_points,
'total_points' => (float) $row->total_points,
])
->values()
->all();
}
private function activeAboQuery(): Builder
{
return UserAbo::query()
->where('active', true)
->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()}).
*/
private function whereNewInMonth(Builder $query, int $month, int $year): Builder
{
$startDate = Carbon::create($year, $month, 1)->startOfMonth();
return $query->whereBetween('start_date', [$startDate, $startDate->copy()->endOfMonth()]);
}
private function userName(User $user): string
{
$name = trim(($user->account?->first_name ?? '').' '.($user->account?->last_name ?? ''));
return $name !== '' ? $name : ($user->email ?: '#'.$user->id);
}
private function careerLevel(User $user): string
{
return $user->user_level?->name ?: ($user->m_level ? 'Level '.$user->m_level : '-');
}
private function aboStatusReason(UserAbo $abo): ?string
{
if ((int) $abo->status === 2) {
return null;
}
$transaction = $abo->user_abo_orders
->sortByDesc('created_at')
->pluck('shopping_order')
->filter()
->map(fn ($order) => $order->getLastShoppingPaymentTransaction())
->filter()
->first();
if (! $transaction) {
return null;
}
$message = $transaction->errormessage ?: $transaction->customermessage;
if (! $message) {
return null;
}
return $transaction->errorcode ? '['.$transaction->errorcode.'] '.$message : $message;
}
private function isAboNewInMonth(UserAbo $abo, int $month, int $year): bool
{
$startDate = $abo->getRawOriginal('start_date');
if (! $startDate) {
return false;
}
$date = Carbon::parse($startDate);
return (int) $date->month === $month && (int) $date->year === $year;
}
private function formatDate(mixed $date): ?string
{
if ($date === null || $date === '') {
return null;
}
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');
}
}