mivita/app/Services/Backoffice/BackofficeDrilldownService.php
Kevin 640a05945b Statistiken: Review-Fixes vor Livegang (Fehldarstellungen + Test-Regression)
Ergebnis des Senior-Reviews über dev/2026-07-22 (Konzept 22.07., Feedback 24.07.,
Plan 19.08.). Rechnerisch war nichts zu korrigieren: Payline-Punkte und Provision
des Prognose-Blocks (N4) stimmen exakt mit der Provisions-Engine überein
(54.718,5 P. bzw. 3.772,88 EUR gegengerechnet an User 454). Behoben sind
Darstellungs- und Konsistenzfehler, die der Beraterin als falsche Zahlen
aufgefallen wären:

- "+N neu" schrumpfte rückwirkend, der Bestand daneben nicht: newAboCount() nutzte
  weiter activeAbo(), während aboStockCount() auf das Monatsende zurückrechnet.
  Beide teilen sich jetzt aboStockQuery(); die Jahres-Wachstumstabelle ebenso.
- Level-Kachel widersprach sich ("Zum nächsten Level Diamant fehlen X" über
  "Karriere-Level: Diamant"): Text auf die Monats-Qualifikation umformuliert
  (de/en/es/fr), Überschrift "Qualifikation in diesem Monat", Karriere-Level nur
  noch bei Abweichung.
- Firstlines-Kachel zählte alle direkten Partner, "Teampartner gesamt" daneben nur
  aktive (F1): Kachel nutzt jetzt isActiveConsultant(), die Detailseite listet
  weiter alle und weist beide Zahlen aus.
- Neupartnerzahlen vergangener Monate sanken auf dem Home-Dashboard, sobald
  Jahresgebühren ausliefen: neue User::newPartnerReferenceDate() (laufender Monat =
  heute, abgeschlossener Monat = Monatsende). Dabei die drei Inline-Kopien der
  Neupartner-Regel auf scopeNewPartnerIn()/isNewPartnerIn() zusammengeführt.
- Top-3-Karte ließ den Betrachter aus, obwohl "ich gehört in die Liste" in der
  Juli-Runde für alle anderen Ranglisten umgesetzt wurde: zählt jetzt mit, inkl.
  is_self und "ich"-Badge.
- Neues Profilfoto zeigte bis zu 6 h ein totes Bild: die URL steckt nicht mehr im
  Cache, sondern entsteht beim Lesen (withProfileImages()).
- Test-Regression seit der Juli-Runde: Unit/Services/BackofficeDashboardServiceTest
  fiel über die fehlende Spalte status_points (kam mit nonPaylinePoints() dazu).
- Kleinigkeiten: ProfileImageController gab die komplette Exception als
  Flash-Message aus (jetzt Log + Sprachkey), BackofficeDrilldownService formatierte
  Datumsangaben hart auf d.m.Y statt über Util::formatDateDB().

Tests: neuer HomeMonthlyStatsTest (buildMonthlyStats war ungetestet) plus Tests für
Stichtags-Zählung und Foto-Cache. Fünf Tests kodierten das alte Verhalten
(Betrachter zählt nicht mit, abgelaufener Account zählt nie) und wurden auf die
neue Semantik umgestellt, jeweils mit Gegenprobe. Suite: 861 grün (vorher 855),
89 rot — ausschließlich die in docs/2026-06-11-test-suite-vorbestehende-fehler.md
dokumentierten Altlasten.

Offen und bewusst nicht enthalten:
- Deploy-Schritt: backoffice:store-statistics-snapshots --force (vorher dumpen).
  Kein vorhandener Snapshot enthält recruiter_ranking, Mai/Juni zusätzlich weder
  non_payline_points noch die Führungskräfte-"gesamt"-Spalten; der Cron überspringt
  vorhandene Snapshots ohne --force. Achtung: die Neuberechnung ist nicht
  verlustfrei.
- TreeHtmlRendererTest/TreeCalcBotIntegrationTest: die Abo-Badges machten den
  Renderer datenbankabhängig; die Dateien waren schon vorher rot und gehören in die
  separate Test-Aufräumaufgabe.
- deleteImage bleibt ein GET-Route (gleiches Muster wie das Shop-Bild).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 09:41:53 +00:00

424 lines
17 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\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);
$userIds = $this->dashboardService->lineUserIds($viewer->id, $line);
if ($metric === 'shop_1000' && $line === 0) {
$userIds[] = (int) $viewer->id;
}
$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),
'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),
'payment_account' => $this->formatDate($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,
'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),
'payment_account' => $this->formatDate($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,
]);
}
/**
* @param int[] $userIds
* @return array<int, array<string, mixed>>
*/
private function partnerAboRows(array $userIds, int $month, int $year, bool $onlyNew = false): array
{
return $this->activeAboQuery()
->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))
->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')),
'is_new_this_month' => $this->isAboNewInMonth($abo, $month, $year),
'next_date' => $this->formatDate($abo->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
{
return $this->activeAboQuery()
->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))
->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->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');
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]);
}
/**
* 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());
}
}