mivita/app/Services/Abo/TeamAboOverviewService.php
Kevin ac3bd6e8ea Statistiken/Abo: Kundenfeedback 2026-07-24 komplett umgesetzt + Steuern: DATEV-Fixes 08+09/2025 (Sammel-Commit Branch-WIP)
Feedback-Umsetzung (dev/2026-07-22/feedback-analyse-2026-07-24.md, alle 10 Punkte):
- VIP-Statistik: eigener Berater ('ich', Linie 0) in Neue-Kundenabos- und beiden Top-10-Listen; Top-10-Umsatz sortiert nach abgerechneten Ist-Punkten + Abo-Anzahl-Spalte; Fußnote nicht-payline-wirksame Punkte (SalesVolumePoints::nonPaylinePoints)
- Führungskräfte-Liste: beide Sichten 'direkt' (an nächster FK abgeschnitten, additiv) und 'gesamt' (komplette Downline), Gesamt-Spalte nach Kundenzählweise
- Team-Abos: je Ebene 'abgerechnet (Monat)' und 'bis zum 25. erwartet'; Payline-Karte mit Teamabo-/Kundenabo-Zählern
- Incentive-Live-Ranking: Qualifizierte zuerst nummeriert (fixt auch isWinner rank<=max_winners)
- Porto: Netto-/Brutto-Kennzeichnung (Berater netto, Endkunden inkl. MwSt., portal_view-Flag; auch CRM-/Shop-Warenkorb + Checkout), Yard::shipping_tax-Fix
- Struktur-Ansicht: Abo-Badges 'A' (eigenes Abo) und '(n)' (Kundenabos) je Teampartner, gebatcht
- Abo-Löschung: Softdelete setzt active=0 (UserAbo deleting-Hook) + Bereinigungs-Migration für gelöschte-aber-aktive Abos
- Doku: technische Analyse + Kundinnen-Feedback-Dokument unter dev/2026-07-22/
- Tests: IncentiveRankingTest, TeamStructureAboBadgesTest, YardShippingTaxTest (neu); BackofficeStatisticsListsTest, TeamAboOverviewTest, PaylineForecastServiceTest, AboAdminDeleteTest, AboOneTimeViewTest erweitert

Außerdem enthalten (Branch-WIP vor dem Feedback): DATEV-Abgleich-Fixes 08+09/2025, USt-ID-Live-Validierung sowie restliche unkommittete Stände (u. a. Abo-Pause-Sprachkeys).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 13:56:02 +00:00

497 lines
18 KiB
PHP

<?php
namespace App\Services\Abo;
use App\Models\UserAbo;
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).
*
* @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 = UserAbo::query()->activeAbo()->where('is_for', 'me')->whereIn('user_id', $teamUserIds)->count();
$customerAbos = UserAbo::query()->activeAbo()->where('is_for', 'ot')->whereIn('member_id', $teamUserIds)->count();
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.
*
* @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' => UserAbo::query()->activeAbo()->where('is_for', 'me')->where('user_id', $userId)->count(),
'own_abos_new' => $this->newAboCount([$userId], 'user_id', 'me', $month, $year),
'own_customer_abos' => UserAbo::query()->activeAbo()->where('is_for', 'ot')->where('member_id', $userId)->count(),
'own_customer_abos_new' => $this->newAboCount([$userId], 'member_id', 'ot', $month, $year),
];
}
/**
* 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();
$ownByMonth = $teamUserIds === [] ? [] : $this->newAbosByMonth($teamUserIds, 'user_id', 'me', $year);
$customerByMonth = $teamUserIds === [] ? [] : $this->newAbosByMonth($teamUserIds, 'member_id', 'ot', $year);
$months = [];
$totals = ['own_new' => 0, 'customer_new' => 0, 'total_new' => 0];
for ($month = 1; $month <= 12; $month++) {
$own = $ownByMonth[$month] ?? 0;
$customer = $customerByMonth[$month] ?? 0;
$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,
];
}
/**
* Zählt neue (aktive) Abos je Monat eines Jahres anhand start_date.
* DB-agnostisch (kein MONTH()/strftime): rohe Datumswerte werden in PHP gruppiert.
*
* @param int[] $userIds
* @return array<int, int>
*/
private function newAbosByMonth(array $userIds, string $userColumn, string $isFor, int $year): array
{
$dates = UserAbo::query()
->activeAbo()
->where('is_for', $isFor)
->whereIn($userColumn, $userIds)
->whereYear('start_date', $year)
->toBase()
->pluck('start_date');
$byMonth = [];
foreach ($dates as $date) {
if (! $date) {
continue;
}
$month = (int) Carbon::parse($date)->month;
$byMonth[$month] = ($byMonth[$month] ?? 0) + 1;
}
return $byMonth;
}
/**
* 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 mit next_date nach
* heute und im angefragten Monat. 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();
if ($windowEnd->lte($today->copy()->endOfDay())) {
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)
->where('user_abos.next_date', '>', $today->copy()->endOfDay())
->where('user_abos.next_date', '<=', $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);
}
/**
* @param int[] $userIds
*/
private function newAboCount(array $userIds, string $userColumn, string $isFor, int $month, int $year): int
{
$startDate = Carbon::create($year, $month, 1)->startOfMonth();
$endDate = Carbon::create($year, $month, 1)->endOfMonth();
return UserAbo::query()
->activeAbo()
->whereIn($userColumn, $userIds)
->where('is_for', $isFor)
->whereBetween('start_date', [$startDate, $endDate])
->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;
}
}