A1: Jeder Monatstag ist als Liefertag erlaubt (UserAbo::allowedDeliveryDays), Tage 29-31 liefern in kuerzeren Monaten am letzten Tag. Eine Stelle AboHelper::dateForDayInMonth klammert; setNextDate, Erstausfuehrung, Admin-Termin und Pause rechnen darueber, der Folgemonat immer aus dem Liefertag (31.01 -> 28.02 -> 31.03). Liefertag-Aenderung gilt fuer den laufenden Zyklus und nur nach vorn: ist der Tag im Referenzmonat vorbei, wird abgelehnt, der Monat verschiebt sich nie (Rueckfrage R1). Admin-Filter "naechste Ausfuehrung" als Datumsbereich. Prognose-Kacheln bekommen die Kachel Monatsende, Team-Abos rechnen bis Monatsende. A2: Sperrfristen Default 0, Setting 0 ist ausdruecklich 0 (fiel bisher auf den Default zurueck). Bedingung Kalendertage <= Sperre, zentral in AboHelper::isWithinLock: am Ausfuehrungstag ab 0:00 Uhr fixiert, Vortag bis 23:59 Uhr frei, fuer Liefertag, Produkte, Pause und Kuendigung (R2). Texte mit _zero-Varianten ueber AboHelper::lockMessage. Sprachdateien de/en/es/fr, neuer AboDeliveryDayTest, bestehende Tests angepasst, Plan-Status mit Deploy-Schritt (Settings auf 0). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
567 lines
23 KiB
PHP
567 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;
|
|
}
|
|
|
|
// Ticket A1: Liefertage sind frei (1-31), das Fenster reicht bis Monatsende.
|
|
// A13: Am Liefertag selbst ist das Fenster noch offen, solange der
|
|
// Ausführungsjob nicht gelaufen ist.
|
|
$windowEnd = Carbon::create($year, $month, 1)->endOfMonth();
|
|
|
|
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;
|
|
}
|
|
}
|