A01 — Abo-Regeln waren ueber das Aktiv-Feld umgehbar. Das Selbstbedienungsformular hat gar keinen Aktiv-Schalter, es spiegelt den Zustand nur in einem versteckten Feld zurueck; `updateStatus()` hat den Wert trotzdem uebernommen. Wer den Request manipulierte, konnte sein eigenes Abo dauerhaft deaktivieren und so die Mindestlaufzeit umgehen, am Pausenpfad mit seinen Sperren vorbei. Das Feld wirkt jetzt nur noch im Admin-Pfad. A16 — Statuswechsel wurden nur aus dem Formular protokolliert. Recovery-Zahlung, Retry, externe Erledigung und die DSGVO-Anonymisierung schreiben `active` direkt auf das Modell. Das ist heikel, weil die historische Bestandsrueckrechnung ein Abo verwirft, solange eine protokollierte Deaktivierung nicht durch eine spaetere protokollierte Reaktivierung aufgehoben ist — ein stiller `active = true` nimmt es dauerhaft aus allen Folgemonaten. Der Eintrag liegt deshalb jetzt am Model (`UserAbo::logStatusTransition()`) und nicht in den einzelnen Diensten: so kann ihn ein kuenftiger Schreibpfad nicht vergessen, und er entsteht in derselben Transaktion wie die Statusaenderung. Nebeneffekt: Die DSGVO-Anonymisierung setzt `active = false` ohne `cancel_date` — bisher fiel ein so abgeschaltetes Abo rueckwirkend aus allen vergangenen Monaten, jetzt bleibt die Historie korrekt. A13 — Ein heute faelliges Abo galt ab Mitternacht als erledigt, der Ausfuehrungsjob laeuft aber erst um 04:00. In diesem Fenster fehlten die Punkte in der Prognose UND im Ist. Der neue Scope `UserAbo::scopeOpenExecutionUntil()` zaehlt den heutigen Liefertag mit, solange keine Ausfuehrung angelegt ist — genau diese Bedingung verhindert die Doppelzaehlung. Alle drei Prognosen nutzen jetzt denselben Scope statt je einer eigenen Kopie der Datumsregel. A11 — Der Namens-Tiebreak der Top 3 lief nach dem Abschneiden auf 25 Kandidaten, bei Gleichstand entschied also die Reihenfolge der Abfrage. Die Auswahlgrenze wird jetzt um alle Gleichstaende an dieser Grenze erweitert, und Kandidaten ohne Account fallen vorher weg statt die Plaetze zu blockieren. Als letzte Regel entscheidet die user_id, damit eine gecachte Rangliste reproduzierbar bleibt. A14 — Sortiert wurde ueber den angezeigten Zellentext. Unter `en` liefert die Anwendung `Y-m-d` und `1,000.00`, damit ergab jedes Datum 0 und `1,000.00` wurde zur 1. Die Zellen tragen jetzt `data-sort-value` mit dem unformatierten Wert; ohne Attribut gilt weiter der Textweg. Dabei mitgenommen: Umlaute landeten hinter dem Z (jetzt localeCompare), leere Werte lagen als 0 zwischen den negativen Stornowerten, und `next_date` wurde doppelt formatiert. Ausserdem A08 (Prognose auch ohne erreichte Qualifikationsstufe), A09 (Summenzeile im Neupartner-CSV), A10 (Lieferungszahl vorgeladen), A17 (Faelligkeitsgruppen ohne Monatsueberlauf), A18 (Formelinhalte im CSV als Text) und A19 (fehlgeschlagene Provisionsberechnung ist als solche erkennbar statt 0,00 EUR). Audit-Befunde A01, A08, A09, A10, A11, A13, A14, A16, A17, A18, A19. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
568 lines
23 KiB
PHP
568 lines
23 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Abo;
|
|
|
|
use App\Models\UserAbo;
|
|
use App\Models\UserAboStatusHistory;
|
|
use App\Models\UserSalesVolume;
|
|
use App\Services\AboHelper;
|
|
use App\Services\Backoffice\BackofficeDashboardService;
|
|
use App\Services\Backoffice\SalesVolumePoints;
|
|
use App\Services\HTMLHelper;
|
|
use App\User;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Support\Collection;
|
|
|
|
/**
|
|
* Abo-fokussierte Team-Übersicht (für alle aktiven Berater, nicht nur VIP).
|
|
*
|
|
* Gruppiert die Downline nach Linien/Ebenen und liefert pro Ebene die Abo-Kennzahlen:
|
|
* wer hat selbst ein Abo, wie viele Kundenabos, sowie die im gewählten Monat neu
|
|
* hinzugekommenen Abos. Wiederverwendet die Linien-Logik aus
|
|
* {@see BackofficeDashboardService} (keine Duplizierung der Baum-Traversierung).
|
|
*/
|
|
class TeamAboOverviewService
|
|
{
|
|
public function __construct(private BackofficeDashboardService $dashboardService) {}
|
|
|
|
/**
|
|
* @return array{
|
|
* month: int,
|
|
* year: int,
|
|
* lines: array<int, array<string, mixed>>,
|
|
* totals: array<string, mixed>
|
|
* }
|
|
*/
|
|
public function linesOverview(User $user, int $month, int $year): array
|
|
{
|
|
$lineBuckets = $this->dashboardService->lineBuckets($user->id);
|
|
|
|
$lines = [];
|
|
$totals = $this->emptyLine(0);
|
|
$totals['label'] = __('abo.team_total');
|
|
|
|
foreach ($lineBuckets as $line => $users) {
|
|
$row = $this->buildLineRow((int) $line, $users, $month, $year);
|
|
$lines[$line] = $row;
|
|
$totals = $this->addToTotals($totals, $row);
|
|
}
|
|
|
|
return [
|
|
'month' => $month,
|
|
'year' => $year,
|
|
'lines' => $lines,
|
|
'totals' => $totals,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Leichtgewichtige Kennzahlen für das Dashboard (ohne Punkte-/Mitglieder-Details).
|
|
*
|
|
* Abo-Bestände sind monatsbezogen (Kundenwunsch 2026-08-19, N2): im laufenden
|
|
* Monat der Live-Bestand, für vergangene Monate der Bestand zum Monatsende
|
|
* ({@see aboStockCount()}); die VIP-Statistik bleibt bewusst tagesaktuell (F7/F3).
|
|
*
|
|
* @return array{team_total_abos: int, team_partner_abos: int, team_partner_abos_new: int, team_customer_abos: int, team_customer_abos_new: int, shop_1000: int, new_partners: int}
|
|
*/
|
|
public function teamKpis(User $user, int $month, int $year): array
|
|
{
|
|
$teamUserIds = $this->dashboardService->activeTeamUserIds($user->id);
|
|
|
|
// 1000-Punkte-Shop: der Berater selbst zählt mit (Kundenwunsch), alle
|
|
// übrigen Team-Kennzahlen bleiben ohne den Betrachter.
|
|
$shop1000UserIds = array_merge([(int) $user->id], $teamUserIds);
|
|
|
|
if ($teamUserIds === []) {
|
|
return [
|
|
'team_total_abos' => 0,
|
|
'team_partner_abos' => 0,
|
|
'team_partner_abos_new' => 0,
|
|
'team_customer_abos' => 0,
|
|
'team_customer_abos_new' => 0,
|
|
'shop_1000' => $this->dashboardService->shop1000Count($shop1000UserIds, $month, $year),
|
|
'new_partners' => 0,
|
|
];
|
|
}
|
|
|
|
$partnerAbos = $this->aboStockCount($teamUserIds, 'user_id', 'me', $month, $year);
|
|
$customerAbos = $this->aboStockCount($teamUserIds, 'member_id', 'ot', $month, $year);
|
|
|
|
return [
|
|
'team_total_abos' => $partnerAbos + $customerAbos,
|
|
'team_partner_abos' => $partnerAbos,
|
|
'team_partner_abos_new' => $this->newAboCount($teamUserIds, 'user_id', 'me', $month, $year),
|
|
'team_customer_abos' => $customerAbos,
|
|
'team_customer_abos_new' => $this->newAboCount($teamUserIds, 'member_id', 'ot', $month, $year),
|
|
'shop_1000' => $this->dashboardService->shop1000Count($shop1000UserIds, $month, $year),
|
|
'new_partners' => $this->dashboardService->newPartnersCount($teamUserIds, $month, $year),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Persönliche Abo-Kennzahlen des Beraters selbst (eigenes Abo + eigene Kundenabos),
|
|
* konsistent mit der activeAbo-Definition. Inklusive der im Monat neu hinzugekommenen.
|
|
* Bestände monatsbezogen wie bei {@see teamKpis()}.
|
|
*
|
|
* @return array{own_abos: int, own_abos_new: int, own_customer_abos: int, own_customer_abos_new: int}
|
|
*/
|
|
public function personalAboKpis(User $user, int $month, int $year): array
|
|
{
|
|
$userId = (int) $user->id;
|
|
|
|
return [
|
|
'own_abos' => $this->aboStockCount([$userId], 'user_id', 'me', $month, $year),
|
|
'own_abos_new' => $this->newAboCount([$userId], 'user_id', 'me', $month, $year),
|
|
'own_customer_abos' => $this->aboStockCount([$userId], 'member_id', 'ot', $month, $year),
|
|
'own_customer_abos_new' => $this->newAboCount([$userId], 'member_id', 'ot', $month, $year),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Abo-Bestand monatsbezogen (N2): Für den laufenden (oder einen zukünftigen)
|
|
* Monat der heutige Live-Bestand; für vergangene Monate der rückgerechnete
|
|
* Bestand zum Monatsende — Abo gestartet bis Monatsende und danach erst
|
|
* beendet (heute noch aktiv, später gekündigt/gelöscht oder laut
|
|
* Status-Historie erst später deaktiviert).
|
|
*
|
|
* Näherung für Monate vor Einführung der Status-Historie (07/2026):
|
|
* Deaktivierungen ohne cancel_date sind dort nicht datierbar — solche Abos
|
|
* fehlen im historischen Bestand.
|
|
*
|
|
* @param int[] $userIds
|
|
*/
|
|
public function aboStockCount(array $userIds, string $userColumn, string $isFor, int $month, int $year): int
|
|
{
|
|
return $this->aboStockQuery($userIds, $userColumn, $isFor, $month, $year)->count();
|
|
}
|
|
|
|
/**
|
|
* Die Bestandsabfrage hinter {@see aboStockCount()} — öffentlich, damit jede
|
|
* "+N neu"-Zählung exakt dieselbe Stichtags-Logik verwendet und nicht in
|
|
* derselben Kachel eine andere Zählweise steht. Wer nur eine Gesamtzahl
|
|
* braucht, nimmt {@see newAboCount()}; gruppierte Auswertungen
|
|
* ({@see TopResultsService}) setzen direkt auf dieser Abfrage auf.
|
|
*
|
|
* @param int[] $userIds
|
|
*/
|
|
public function aboStockQuery(array $userIds, string $userColumn, string $isFor, int $month, int $year): Builder
|
|
{
|
|
$endOfMonth = Carbon::create($year, $month, 1)->endOfMonth();
|
|
|
|
$query = UserAbo::query()
|
|
->where('is_for', $isFor)
|
|
->whereIn($userColumn, $userIds);
|
|
|
|
if ($endOfMonth->gte(now()->endOfMonth())) {
|
|
return $query->activeAbo();
|
|
}
|
|
|
|
return $query
|
|
->withTrashed()
|
|
->where('start_date', '<=', $endOfMonth)
|
|
// Bis zum Stichtag datierbar beendet? (Kündigung/Löschung vor Monatsende)
|
|
->where(fn ($q) => $q->whereNull('cancel_date')->orWhere('cancel_date', '>', $endOfMonth))
|
|
->where(fn ($q) => $q->whereNull('deleted_at')->orWhere('deleted_at', '>', $endOfMonth))
|
|
// Laut Status-Historie zum Stichtag deaktiviert? (letztes Ereignis bis
|
|
// Monatsende ist deaktivierend und wurde bis dahin nicht reaktiviert)
|
|
->whereNotExists(function ($deactivated) use ($endOfMonth) {
|
|
$deactivated->from('user_abo_status_histories as h')
|
|
->whereColumn('h.user_abo_id', 'user_abos.id')
|
|
->whereIn('h.action', [UserAboStatusHistory::ACTION_CANCELLED, UserAboStatusHistory::ACTION_DEACTIVATED])
|
|
->where('h.created_at', '<=', $endOfMonth)
|
|
->whereNotExists(function ($reactivated) use ($endOfMonth) {
|
|
$reactivated->from('user_abo_status_histories as r')
|
|
->whereColumn('r.user_abo_id', 'h.user_abo_id')
|
|
->where('r.action', UserAboStatusHistory::ACTION_REACTIVATED)
|
|
->where('r.created_at', '<=', $endOfMonth)
|
|
// Gleicher Zeitstempel kommt vor (created_at ist
|
|
// sekundengenau): dann entscheidet die Reihenfolge der
|
|
// Einträge, sonst gilt eine Reaktivierung in derselben
|
|
// Sekunde nicht und das Abo fällt aus dem Bestand.
|
|
->where(function ($later) {
|
|
$later
|
|
->whereColumn('r.created_at', '>', 'h.created_at')
|
|
->orWhere(fn ($sameSecond) => $sameSecond
|
|
->whereColumn('r.created_at', '=', 'h.created_at')
|
|
->whereColumn('r.id', '>', 'h.id')
|
|
);
|
|
});
|
|
});
|
|
})
|
|
// Heute beendete Abos ohne datierbares Ende (z. B. Admin-Toggle vor
|
|
// Einführung der Historie) zählen nur, wenn das Ende nach dem Stichtag lag.
|
|
->where(function ($outer) use ($endOfMonth) {
|
|
$outer
|
|
->where(fn ($active) => $active->activeAbo()->whereNull('deleted_at'))
|
|
->orWhere('cancel_date', '>', $endOfMonth)
|
|
->orWhere('deleted_at', '>', $endOfMonth)
|
|
->orWhereHas('status_histories', function ($history) use ($endOfMonth) {
|
|
$history->whereIn('action', [
|
|
UserAboStatusHistory::ACTION_CANCELLED,
|
|
UserAboStatusHistory::ACTION_DEACTIVATED,
|
|
])->where('created_at', '>', $endOfMonth);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Monatliches Abo-Wachstum des Teams für ein Jahr: je Monat die Anzahl neu
|
|
* hinzugekommener (aktiver) Eigen- und Kunden-Abos anhand des Eingangsmonats
|
|
* (start_date). Dient der separaten Wachstums-Tabelle unter der Bestandsübersicht.
|
|
*
|
|
* @return array{
|
|
* year: int,
|
|
* months: array<int, array{month: int, label: string, own_new: int, customer_new: int, total_new: int}>,
|
|
* totals: array{own_new: int, customer_new: int, total_new: int}
|
|
* }
|
|
*/
|
|
public function monthlyGrowth(User $user, int $year): array
|
|
{
|
|
$teamUserIds = $this->dashboardService->activeTeamUserIds($user->id);
|
|
$monthNames = HTMLHelper::getTransMonths();
|
|
|
|
$months = [];
|
|
$totals = ['own_new' => 0, 'customer_new' => 0, 'total_new' => 0];
|
|
|
|
for ($month = 1; $month <= 12; $month++) {
|
|
// Gleiche Stichtags-Zählung wie die "+N neu"-Badges, damit die
|
|
// Wachstumszahlen vergangener Monate nicht nachträglich sinken.
|
|
$own = $teamUserIds === [] ? 0 : $this->newAboCount($teamUserIds, 'user_id', 'me', $month, $year);
|
|
$customer = $teamUserIds === [] ? 0 : $this->newAboCount($teamUserIds, 'member_id', 'ot', $month, $year);
|
|
|
|
$months[] = [
|
|
'month' => $month,
|
|
'label' => $monthNames[$month] ?? (string) $month,
|
|
'own_new' => $own,
|
|
'customer_new' => $customer,
|
|
'total_new' => $own + $customer,
|
|
];
|
|
|
|
$totals['own_new'] += $own;
|
|
$totals['customer_new'] += $customer;
|
|
$totals['total_new'] += $own + $customer;
|
|
}
|
|
|
|
return [
|
|
'year' => $year,
|
|
'months' => $months,
|
|
'totals' => $totals,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Abo-Zusammenfassung eines einzelnen Team-Mitglieds für das Detail-Modal.
|
|
* Liefert null, wenn das Mitglied nicht in der Downline des Owners liegt.
|
|
*
|
|
* @return array{
|
|
* name: string,
|
|
* email: string,
|
|
* own_abo: array<string, mixed>|null,
|
|
* customer_abos: array{count: int, total_points: float, items: array<int, array<string, mixed>>}
|
|
* }|null
|
|
*/
|
|
public function memberAboSummary(User $owner, int $memberId): ?array
|
|
{
|
|
if (! AboHelper::isUserInTeam($owner->id, $memberId)) {
|
|
return null;
|
|
}
|
|
|
|
$member = User::with('account')->find($memberId);
|
|
|
|
if (! $member) {
|
|
return null;
|
|
}
|
|
|
|
$ownAbo = UserAbo::query()
|
|
->activeAbo()
|
|
->where('is_for', 'me')
|
|
->where('user_id', $memberId)
|
|
->with(['user_abo_items.product', 'user_abo_orders'])
|
|
->orderByDesc('id')
|
|
->first();
|
|
|
|
$customerAbos = UserAbo::query()
|
|
->activeAbo()
|
|
->where('is_for', 'ot')
|
|
->where('member_id', $memberId)
|
|
->with(['user_abo_items.product', 'user_abo_orders'])
|
|
->orderBy('start_date')
|
|
->get();
|
|
|
|
return [
|
|
'name' => $this->memberName($member),
|
|
'email' => $member->email ?? '',
|
|
'own_abo' => $ownAbo ? $this->aboSummary($ownAbo) : null,
|
|
'customer_abos' => [
|
|
'count' => $customerAbos->count(),
|
|
'total_points' => (float) $customerAbos->sum(fn (UserAbo $abo) => $abo->getTotalPoints()),
|
|
'items' => $customerAbos->map(fn (UserAbo $abo) => $this->aboSummary($abo))->all(),
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{points: float, start_date: string, next_date: string, executions: int, amount: string, interval: int}
|
|
*/
|
|
private function aboSummary(UserAbo $abo): array
|
|
{
|
|
return [
|
|
'points' => (float) $abo->getTotalPoints(),
|
|
'start_date' => $abo->start_date,
|
|
'next_date' => $abo->next_date,
|
|
'executions' => $abo->getCountPaidOrders(),
|
|
'amount' => $abo->getFormattedAmount(),
|
|
'interval' => (int) $abo->abo_interval,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param Collection<int, User> $users
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function buildLineRow(int $line, Collection $users, int $month, int $year): array
|
|
{
|
|
// Nur aktive Berater zählen/anzeigen (konsistent mit der Backoffice-Statistik).
|
|
// Die Baum-Traversierung in lineBuckets() bleibt unberührt, damit auch die
|
|
// Downline inaktiver Zwischen-Berater weiterhin erreicht wird.
|
|
$users = $users->filter(fn (User $teamUser) => $this->dashboardService->isActiveConsultant($teamUser))->values();
|
|
|
|
$userIds = $users->pluck('id')->map(fn ($id) => (int) $id)->all();
|
|
|
|
if ($userIds === []) {
|
|
return $this->emptyLine($line);
|
|
}
|
|
|
|
$users->loadMissing('user_level');
|
|
|
|
$ownAbos = $this->ownAbosByUser($userIds);
|
|
$customerAbos = $this->customerAbosByMember($userIds);
|
|
|
|
$members = [];
|
|
$membersWithOwnAbo = 0;
|
|
$ownAbosCount = 0;
|
|
$ownAboPoints = 0.0;
|
|
$customerAbosCount = 0;
|
|
$customerAboPoints = 0.0;
|
|
|
|
foreach ($users as $teamUser) {
|
|
$uid = (int) $teamUser->id;
|
|
$ownAbo = $ownAbos->get($uid);
|
|
$custList = $customerAbos->get($uid, collect());
|
|
|
|
$hasOwnAbo = $ownAbo !== null;
|
|
$memberOwnPoints = $hasOwnAbo ? (float) $ownAbo->getTotalPoints() : 0.0;
|
|
$memberCustomerCount = $custList->count();
|
|
$memberCustomerPoints = (float) $custList->sum(fn (UserAbo $abo) => $abo->getTotalPoints());
|
|
|
|
if ($hasOwnAbo) {
|
|
$membersWithOwnAbo++;
|
|
$ownAbosCount++;
|
|
$ownAboPoints += $memberOwnPoints;
|
|
}
|
|
$customerAbosCount += $memberCustomerCount;
|
|
$customerAboPoints += $memberCustomerPoints;
|
|
|
|
$members[] = [
|
|
'user_id' => $uid,
|
|
'name' => $this->memberName($teamUser),
|
|
'career_level' => $teamUser->user_level?->getLang('name') ?? '',
|
|
'has_own_abo' => $hasOwnAbo,
|
|
'own_abo_points' => $memberOwnPoints,
|
|
'customer_abo_count' => $memberCustomerCount,
|
|
'customer_abo_points' => $memberCustomerPoints,
|
|
'total_abos' => ($hasOwnAbo ? 1 : 0) + $memberCustomerCount,
|
|
];
|
|
}
|
|
|
|
$billedPoints = $this->aboBilledPoints($userIds, $month, $year);
|
|
|
|
return [
|
|
'line' => $line,
|
|
'label' => __('abo.team_line', ['line' => $line]),
|
|
'consultants' => $users->count(),
|
|
'members_total' => $users->count(),
|
|
'members_with_own_abo' => $membersWithOwnAbo,
|
|
'own_abos' => $ownAbosCount,
|
|
'own_abos_new' => $this->newAboCount($userIds, 'user_id', 'me', $month, $year),
|
|
'own_abo_points' => $ownAboPoints,
|
|
'customer_abos' => $customerAbosCount,
|
|
'customer_abos_new' => $this->newAboCount($userIds, 'member_id', 'ot', $month, $year),
|
|
'customer_abo_points' => $customerAboPoints,
|
|
'abo_points_billed' => $billedPoints,
|
|
'abo_points_expected' => $billedPoints + $this->upcomingAboPointsUntilLastDeliveryDay($userIds, $month, $year),
|
|
'total_abos' => $ownAbosCount + $customerAbosCount,
|
|
'members' => $members,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Im Monat bereits abgerechnete Abo-Punkte (Eigen- und Kundenabos) der
|
|
* übergebenen Berater — gleiche Zählweise wie der Payline-Ist-Wert
|
|
* ({@see PaylineForecastService::aboActualPoints()}).
|
|
*
|
|
* @param int[] $userIds
|
|
*/
|
|
private function aboBilledPoints(array $userIds, int $month, int $year): float
|
|
{
|
|
if ($userIds === []) {
|
|
return 0.0;
|
|
}
|
|
|
|
return (float) UserSalesVolume::query()
|
|
->leftJoin('shopping_orders', 'shopping_orders.id', '=', 'user_sales_volumes.shopping_order_id')
|
|
->whereIn('user_id', $userIds)
|
|
->where('month', $month)
|
|
->where('year', $year)
|
|
->selectRaw(SalesVolumePoints::aboPoints().' as points')
|
|
->value('points');
|
|
}
|
|
|
|
/**
|
|
* Abo-Punkte der noch offenen Ausführungen bis einschließlich zum letzten
|
|
* Liefertag (25.) des Monats: aktive Abos der Berater, deren Ausführung noch
|
|
* aussteht ({@see UserAbo::scopeOpenExecutionUntil()}). Für
|
|
* vergangene/zukünftige Monate 0 — die Prognose ergibt nur für den laufenden
|
|
* Monat Sinn.
|
|
*
|
|
* @param int[] $userIds
|
|
*/
|
|
private function upcomingAboPointsUntilLastDeliveryDay(array $userIds, int $month, int $year): float
|
|
{
|
|
$today = Carbon::today();
|
|
|
|
if ($userIds === [] || (int) $today->month !== $month || (int) $today->year !== $year) {
|
|
return 0.0;
|
|
}
|
|
|
|
$lastDeliveryDay = (int) max(UserAbo::$aboDeliveryDays);
|
|
$windowEnd = Carbon::create($year, $month, 1)->addDays($lastDeliveryDay - 1)->endOfDay();
|
|
|
|
// A13: Am 25. selbst ist das Fenster noch offen, solange der
|
|
// Ausführungsjob nicht gelaufen ist — erst ab dem 26. gibt es nichts mehr
|
|
// zu prognostizieren.
|
|
if ($windowEnd->lt($today->copy()->startOfDay())) {
|
|
return 0.0;
|
|
}
|
|
|
|
return (float) UserAbo::query()
|
|
->join('user_abo_items', 'user_abo_items.user_abo_id', '=', 'user_abos.id')
|
|
->join('products', 'products.id', '=', 'user_abo_items.product_id')
|
|
->whereNull('products.deleted_at')
|
|
->where('user_abos.active', true)
|
|
->where('user_abos.status', 2)
|
|
->where('user_abo_items.comp', 0)
|
|
->openExecutionUntil($today, $windowEnd)
|
|
->where(function (Builder $query) use ($userIds) {
|
|
$query->where(fn (Builder $inner) => $inner->where('user_abos.is_for', 'me')->whereIn('user_abos.user_id', $userIds))
|
|
->orWhere(fn (Builder $inner) => $inner->where('user_abos.is_for', 'ot')->whereIn('user_abos.member_id', $userIds));
|
|
})
|
|
->selectRaw('COALESCE(SUM(products.points * user_abo_items.qty), 0) as points')
|
|
->value('points');
|
|
}
|
|
|
|
/**
|
|
* Eigene (me) aktive Abos je Berater, indiziert nach user_id (ein Abo pro Berater).
|
|
*
|
|
* @param int[] $userIds
|
|
* @return Collection<int, UserAbo>
|
|
*/
|
|
private function ownAbosByUser(array $userIds): Collection
|
|
{
|
|
return UserAbo::query()
|
|
->activeAbo()
|
|
->where('is_for', 'me')
|
|
->whereIn('user_id', $userIds)
|
|
->with('user_abo_items.product')
|
|
->get()
|
|
->keyBy(fn (UserAbo $abo) => (int) $abo->user_id);
|
|
}
|
|
|
|
/**
|
|
* Kundenabos (ot) je Berater, gruppiert nach member_id.
|
|
*
|
|
* @param int[] $userIds
|
|
* @return Collection<int, Collection<int, UserAbo>>
|
|
*/
|
|
private function customerAbosByMember(array $userIds): Collection
|
|
{
|
|
return UserAbo::query()
|
|
->activeAbo()
|
|
->where('is_for', 'ot')
|
|
->whereIn('member_id', $userIds)
|
|
->with('user_abo_items.product')
|
|
->get()
|
|
->groupBy(fn (UserAbo $abo) => (int) $abo->member_id);
|
|
}
|
|
|
|
/**
|
|
* Im Monat hinzugekommene Abos — mit derselben Stichtags-Logik wie der
|
|
* Bestand: für vergangene Monate zählt, was am Monatsende bestand, nicht
|
|
* was heute noch aktiv ist. Sonst schrumpft das "+N neu"-Badge rückwirkend,
|
|
* während der Bestand daneben stehen bleibt.
|
|
*
|
|
* @param int[] $userIds
|
|
*/
|
|
public function newAboCount(array $userIds, string $userColumn, string $isFor, int $month, int $year): int
|
|
{
|
|
if ($userIds === []) {
|
|
return 0;
|
|
}
|
|
|
|
$startDate = Carbon::create($year, $month, 1)->startOfMonth();
|
|
|
|
return $this->aboStockQuery($userIds, $userColumn, $isFor, $month, $year)
|
|
->whereBetween('start_date', [$startDate, $startDate->copy()->endOfMonth()])
|
|
->count();
|
|
}
|
|
|
|
private function memberName(User $user): string
|
|
{
|
|
if ($user->account) {
|
|
$name = trim($user->account->first_name.' '.$user->account->last_name);
|
|
if ($name !== '') {
|
|
return $name;
|
|
}
|
|
}
|
|
|
|
return $user->email ?? ('#'.$user->id);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function emptyLine(int $line): array
|
|
{
|
|
return [
|
|
'line' => $line,
|
|
'label' => $line > 0 ? __('abo.team_line', ['line' => $line]) : __('abo.team_total'),
|
|
'consultants' => 0,
|
|
'members_total' => 0,
|
|
'members_with_own_abo' => 0,
|
|
'own_abos' => 0,
|
|
'own_abos_new' => 0,
|
|
'own_abo_points' => 0.0,
|
|
'customer_abos' => 0,
|
|
'customer_abos_new' => 0,
|
|
'customer_abo_points' => 0.0,
|
|
'abo_points_billed' => 0.0,
|
|
'abo_points_expected' => 0.0,
|
|
'total_abos' => 0,
|
|
'members' => [],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $totals
|
|
* @param array<string, mixed> $row
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function addToTotals(array $totals, array $row): array
|
|
{
|
|
foreach (['consultants', 'members_total', 'members_with_own_abo', 'own_abos', 'own_abos_new', 'own_abo_points', 'customer_abos', 'customer_abos_new', 'customer_abo_points', 'abo_points_billed', 'abo_points_expected', 'total_abos'] as $key) {
|
|
$totals[$key] += $row[$key];
|
|
}
|
|
|
|
return $totals;
|
|
}
|
|
}
|