F1: partner_paid_at wird beim Rechnungskauf (paymentStatusPaidAction mit paid=false) nicht mehr gesetzt, sondern erst bei der Rechnungszahlung; der Backfill nimmt das Aktivierungsdatum nur noch ohne jede Registrierungsbestellung (offene Rechnung bleibt leer). F2: Snapshots speichern je Linie new_partner_ids, die Neupartner-Liste zeigt genau diese; aeltere Snapshots ohne IDs rechnen mit der damaligen Regel (Registrierungsmonat, scopeNewPartnerInByActiveDate), damit Kachel und Liste uebereinstimmen. Keine Payload-Versionsaenderung, kein Neuaufbau noetig. F3: Liefertag-/Termin-Aenderung waehrend einer Pause zieht paused_until mit; eine Ausfuehrung loescht auch den paused_by_*-Grund. F4: expiredCount der Firstlines-Seite (Controller, View, Sprachen) zum bereits committeten Test nachgezogen. F5: Backfills schreiben ohne updated_at (toBase()->update, timestamps=false). Kuendigungsbestaetigung abo_cancel_confirm wird im Selfservice serverseitig verlangt. AboStatusExplainer laedt die Historie je Abo und Aktion nur einmal. Stellungnahme und Deploy-Folgen im Audit-Dokument, Regressionstests je Befund. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
612 lines
25 KiB
PHP
612 lines
25 KiB
PHP
<?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, string $tree = BackofficeDashboardService::TREE_COMMISSION): array
|
||
{
|
||
$tree = BackofficeDashboardService::normalizeTree($tree);
|
||
$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, $tree);
|
||
|
||
$userIds = $frozen !== null
|
||
? $this->frozenUserIds($frozen, $line)
|
||
: $this->dashboardService->lineUserIds($viewer->id, $line, $month, $year, $tree);
|
||
|
||
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, $frozen !== null ? $this->frozenNewPartnerIds($frozen, $line) : null, $frozen !== null),
|
||
'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,
|
||
'tree' => $tree,
|
||
'tree_label' => BackofficeDashboardService::treeLabels()[$tree] ?? $tree,
|
||
'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'),
|
||
'storno_floor_points' => (float) collect($rows)->sum('storno_floor_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 $frozenIds = null, bool $frozen = false): array
|
||
{
|
||
// Eingefrorener Monat: exakt die im Snapshot gezaehlten Partner. Aeltere
|
||
// Snapshots ohne gespeicherte IDs wurden mit der Regel "Registrierungsmonat"
|
||
// gebaut — die Liste rechnet dann mit derselben Regel, sonst zeigt die
|
||
// Kachel 1 und die Liste 0 (Audit 18.09., F2).
|
||
$partners = User::query()
|
||
->with(['account', 'user_level'])
|
||
->whereIn('id', $frozenIds ?? $userIds)
|
||
->when($frozenIds === null && $frozen, fn (Builder $query) => $query->newPartnerInByActiveDate($month, $year))
|
||
->when($frozenIds === null && ! $frozen, fn (Builder $query) => $query->newPartnerIn($month, $year))
|
||
->orderBy('partner_paid_at')
|
||
->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),
|
||
// Massgeblich fuer die Zugehoerigkeit zum Monat (User::scopeNewPartnerIn()).
|
||
'paid_at' => $this->formatDate($user->partner_paid_at),
|
||
'paid_at_sort' => $this->sortDate($user->partner_paid_at),
|
||
'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, string $tree): ?array
|
||
{
|
||
$snapshot = BackofficeStatisticsSnapshot::query()
|
||
->where('user_id', $viewer->id)
|
||
->where('month', $month)
|
||
->where('year', $year)
|
||
->where('tree', $tree)
|
||
->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));
|
||
}
|
||
|
||
/**
|
||
* Neupartner einer Linie zum Monatsabschluss; null, wenn der Snapshot die IDs
|
||
* noch nicht kennt (gebaut vor dem 18.09.2026) — dann gilt die damalige Regel.
|
||
*
|
||
* @param array<int, array<string, mixed>> $frozen
|
||
* @return int[]|null
|
||
*/
|
||
private function frozenNewPartnerIds(array $frozen, int $line): ?array
|
||
{
|
||
$ids = [];
|
||
|
||
foreach ($frozen as $index => $row) {
|
||
if ($line !== 0 && (int) ($row['line'] ?? $index) !== $line) {
|
||
continue;
|
||
}
|
||
|
||
if (! array_key_exists('new_partner_ids', $row)) {
|
||
return null;
|
||
}
|
||
|
||
foreach ($row['new_partner_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')
|
||
->selectRaw(SalesVolumePoints::commissionPoints().' as commission_points')
|
||
->groupBy('user_sales_volumes.user_id');
|
||
|
||
// `<> 0` statt `> 0`: ein Berater mit negativem Monatssaldo (Storno) steckt in
|
||
// der Uebersichtssumme (dort mit 0 gewertet, siehe Knotenregel) 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::commissionPoints().' <> 0');
|
||
}
|
||
|
||
if ($metric === 'shop_1000') {
|
||
$rows->havingRaw(SalesVolumePoints::totalPoints().' >= 1000');
|
||
}
|
||
|
||
// „Gesamtpunkte" der Liste = Provisionspunkte mit Knotenregel (negativer Saldo
|
||
// = 0), damit die Listensumme der Kachel „Umsatz (Punkte)" entspricht. Die
|
||
// 1000-Punkte-Liste behaelt ihre eigene Definition (Eigen- + Kundenpunkte).
|
||
$salesRows = $rows->orderByDesc($metric === 'shop_1000' ? 'total_points' : 'commission_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' => $metric === 'shop_1000' ? (float) $row->total_points : max(0.0, (float) $row->commission_points),
|
||
'storno_floor_points' => $metric === 'shop_1000' ? 0.0 : max(0.0, -(float) $row->commission_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');
|
||
}
|
||
}
|