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>
250 lines
9.5 KiB
PHP
250 lines
9.5 KiB
PHP
<?php
|
||
|
||
namespace App\Services\Abo;
|
||
|
||
use App\Models\UserAbo;
|
||
use App\Models\UserSalesVolume;
|
||
use App\Services\Backoffice\BackofficeDashboardService;
|
||
use App\Services\Backoffice\SalesVolumePoints;
|
||
use App\Services\BusinessPlan\LiveCommissionService;
|
||
use App\User;
|
||
use Carbon\Carbon;
|
||
use Illuminate\Database\Eloquent\Builder;
|
||
use Illuminate\Support\Facades\Cache;
|
||
|
||
/**
|
||
* Payline-bezogene Abo-Kennzahlen (Konzept B8 + B10):
|
||
*
|
||
* - Payline = die ersten N Ebenen der Downline; N ("paylines") kommt aus dem
|
||
* Provisionslevel des Users (Live-TreeCalcBot bzw. UserBusiness-Snapshot).
|
||
* - B8 aboTurnover(): Abo-Gesamtumsatz der Payline als Soll (Abo-Punkte-Bestand
|
||
* pro Zyklus) und Ist (im Monat abgerechnete Abo-Punkte, F10).
|
||
* - B10 forecastDays(): voraussichtlicher Payline-Gesamtumsatz zu den festen
|
||
* Ausführungsterminen 5./10./20./25. — Basis (Kundenumsatz + Payline-Punkte
|
||
* Stand heute) plus die bis zum jeweiligen Termin noch anstehenden
|
||
* Abo-Punkte, kumuliert (F11).
|
||
*/
|
||
class PaylineForecastService
|
||
{
|
||
/**
|
||
* F12 (dokumentierte Annahme, Kunde entscheidet nach Umsetzung): Die eigenen
|
||
* Abos und eigenen Kundenabos des Beraters zählen in der Prognose MIT, weil
|
||
* die Basis der Formel den aktuellen Kundenumsatz enthält und kommende
|
||
* eigene Ausführungen genau diesen Anteil erhöhen. Über diesen Schalter
|
||
* ohne Umbau umkehrbar.
|
||
*/
|
||
public const INCLUDE_OWN_ABOS = true;
|
||
|
||
private const CACHE_HOURS = 6;
|
||
|
||
public function __construct(
|
||
private BackofficeDashboardService $dashboardService,
|
||
private LiveCommissionService $commissionService,
|
||
) {}
|
||
|
||
/**
|
||
* Payline-Kontext (Anzahl bezahlter Ebenen + Mitglieder-IDs), 6 h gecacht —
|
||
* die Payline-Tiefe kommt aus dem gemeinsamen Live-Lauf, die Abo-Summen
|
||
* bleiben live. Ein fehlgeschlagener Live-Lauf wird nicht gecacht, damit
|
||
* ein transienter Fehler die Karte nicht 6 h lang leert.
|
||
*
|
||
* @return array{paylines: int, member_ids: int[]}
|
||
*/
|
||
public function paylineContext(User $user, int $month, int $year): array
|
||
{
|
||
$cacheKey = "payline_context_{$user->id}_{$month}_{$year}";
|
||
$cached = Cache::get($cacheKey);
|
||
if (is_array($cached)) {
|
||
return $cached;
|
||
}
|
||
|
||
[$context, $failed] = $this->buildPaylineContext($user, $month, $year);
|
||
|
||
if (! $failed) {
|
||
Cache::put($cacheKey, $context, now()->addHours(self::CACHE_HOURS));
|
||
}
|
||
|
||
return $context;
|
||
}
|
||
|
||
/**
|
||
* B8: Abo-Gesamtumsatz in der Payline — Soll und Ist, plus die Anzahl der
|
||
* dahinterstehenden Abos (Berater- und Kundenabos, Kundenwunsch 2026-07-24).
|
||
*
|
||
* @return array{paylines: int, members: int, target: float, actual: float, team_abo_count: int, team_customer_abo_count: int}
|
||
*/
|
||
public function aboTurnover(User $user, int $month, int $year): array
|
||
{
|
||
$context = $this->paylineContext($user, $month, $year);
|
||
$memberIds = $context['member_ids'];
|
||
|
||
return [
|
||
'paylines' => $context['paylines'],
|
||
'members' => count($memberIds),
|
||
'target' => $this->aboTargetPoints($memberIds),
|
||
'actual' => $this->aboActualPoints($memberIds, $month, $year),
|
||
'team_abo_count' => $this->aboCount($memberIds, 'me', 'user_id'),
|
||
'team_customer_abo_count' => $this->aboCount($memberIds, 'ot', 'member_id'),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Anzahl aktiver Abos der Payline-Mitglieder (gleiche Filter wie Soll).
|
||
*
|
||
* @param int[] $memberIds
|
||
*/
|
||
private function aboCount(array $memberIds, string $isFor, string $userColumn): int
|
||
{
|
||
if ($memberIds === []) {
|
||
return 0;
|
||
}
|
||
|
||
return UserAbo::query()
|
||
->where('active', true)
|
||
->whereNotIn('status', [4, 5, 6])
|
||
->where('is_for', $isFor)
|
||
->whereIn($userColumn, $memberIds)
|
||
->count();
|
||
}
|
||
|
||
/**
|
||
* B10: Prognose je Ausführungstermin des LAUFENDEN Monats. Basis ist der
|
||
* heutige Stand (Kundenumsatz + Payline-Punkte, vom Aufrufer übergeben);
|
||
* dazu kommen kumuliert die Abo-Punkte aller noch offenen Ausführungen bis
|
||
* zum jeweiligen Termin (next_date > heute, im laufenden Monat). Bereits
|
||
* ausgeführte Abos stecken im Ist-Umsatz und werden nicht doppelt gezählt.
|
||
*
|
||
* @return array{base: float, days: array<int, array{day: int, date: string, upcoming: float, total: float, is_past: bool}>}
|
||
*/
|
||
public function forecastDays(User $user, float $basePoints): array
|
||
{
|
||
$today = Carbon::today();
|
||
$context = $this->paylineContext($user, (int) $today->month, (int) $today->year);
|
||
|
||
$upcomingAbos = $this->upcomingAboQuery($user, $context['member_ids'], $today)
|
||
->with('user_abo_items.product')
|
||
->get();
|
||
|
||
$pointsByDay = [];
|
||
foreach ($upcomingAbos as $abo) {
|
||
$day = (int) Carbon::parse($abo->getRawOriginal('next_date'))->day;
|
||
$pointsByDay[$day] = ($pointsByDay[$day] ?? 0.0) + (float) $abo->getTotalPoints();
|
||
}
|
||
|
||
$days = [];
|
||
foreach (UserAbo::$aboDeliveryDays as $deliveryDay) {
|
||
$upcoming = 0.0;
|
||
foreach ($pointsByDay as $day => $points) {
|
||
if ($day <= $deliveryDay) {
|
||
$upcoming += $points;
|
||
}
|
||
}
|
||
|
||
$days[] = [
|
||
'day' => $deliveryDay,
|
||
'date' => $today->copy()->startOfMonth()->addDays($deliveryDay - 1)->format(\Util::formatDateDB()),
|
||
'upcoming' => $upcoming,
|
||
'total' => $basePoints + $upcoming,
|
||
'is_past' => $deliveryDay < (int) $today->day,
|
||
];
|
||
}
|
||
|
||
return [
|
||
'base' => $basePoints,
|
||
'days' => $days,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @return array{0: array{paylines: int, member_ids: int[]}, 1: bool} [Kontext, Lauf fehlgeschlagen]
|
||
*/
|
||
private function buildPaylineContext(User $user, int $month, int $year): array
|
||
{
|
||
$snapshot = $this->commissionService->snapshot($user, $month, $year);
|
||
$paylines = $snapshot['paylines'];
|
||
|
||
if ($paylines <= 0) {
|
||
return [['paylines' => 0, 'member_ids' => []], (bool) ($snapshot['failed'] ?? false)];
|
||
}
|
||
|
||
$memberIds = collect($this->dashboardService->lineBuckets($user->id))
|
||
->filter(fn ($users, int $line) => $line <= $paylines)
|
||
->flatMap(fn ($users) => $users->pluck('id'))
|
||
->map(fn ($id) => (int) $id)
|
||
->values()
|
||
->all();
|
||
|
||
return [['paylines' => $paylines, 'member_ids' => $memberIds], false];
|
||
}
|
||
|
||
/**
|
||
* Soll: Abo-Punkte-Bestand (Produktpunkte × Menge, Hauptartikel comp = 0)
|
||
* aller aktiven Abos der Payline-Mitglieder — Berater-Abos und Kundenabos.
|
||
*
|
||
* @param int[] $memberIds
|
||
*/
|
||
private function aboTargetPoints(array $memberIds): float
|
||
{
|
||
if ($memberIds === []) {
|
||
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)
|
||
->whereNotIn('user_abos.status', [4, 5, 6])
|
||
->where('user_abo_items.comp', 0)
|
||
->where(function (Builder $query) use ($memberIds) {
|
||
$query->where(fn (Builder $inner) => $inner->where('user_abos.is_for', 'me')->whereIn('user_abos.user_id', $memberIds))
|
||
->orWhere(fn (Builder $inner) => $inner->where('user_abos.is_for', 'ot')->whereIn('user_abos.member_id', $memberIds));
|
||
})
|
||
->selectRaw('COALESCE(SUM(products.points * user_abo_items.qty), 0) as points')
|
||
->value('points');
|
||
}
|
||
|
||
/**
|
||
* Ist: im Monat abgerechnete Abo-Punkte der Payline-Mitglieder.
|
||
*
|
||
* @param int[] $memberIds
|
||
*/
|
||
private function aboActualPoints(array $memberIds, int $month, int $year): float
|
||
{
|
||
if ($memberIds === []) {
|
||
return 0.0;
|
||
}
|
||
|
||
return (float) UserSalesVolume::query()
|
||
->leftJoin('shopping_orders', 'shopping_orders.id', '=', 'user_sales_volumes.shopping_order_id')
|
||
->whereIn('user_id', $memberIds)
|
||
->where('month', $month)
|
||
->where('year', $year)
|
||
->selectRaw(SalesVolumePoints::aboPoints().' as points')
|
||
->value('points');
|
||
}
|
||
|
||
/**
|
||
* Noch offene Abo-Ausführungen des laufenden Monats: aktive Abos der
|
||
* Payline-Mitglieder plus — je nach {@see INCLUDE_OWN_ABOS} — die eigenen
|
||
* Abos/Kundenabos des Betrachters.
|
||
*
|
||
* @param int[] $memberIds
|
||
*/
|
||
private function upcomingAboQuery(User $user, array $memberIds, Carbon $today): Builder
|
||
{
|
||
$aboUserIds = self::INCLUDE_OWN_ABOS
|
||
? array_values(array_unique(array_merge([(int) $user->id], $memberIds)))
|
||
: $memberIds;
|
||
|
||
return UserAbo::query()
|
||
->where('active', true)
|
||
->where('status', 2)
|
||
->where('next_date', '>', $today->copy()->endOfDay())
|
||
->where('next_date', '<=', $today->copy()->endOfMonth())
|
||
->where(function (Builder $query) use ($aboUserIds) {
|
||
$query->where(fn (Builder $inner) => $inner->where('is_for', 'me')->whereIn('user_id', $aboUserIds))
|
||
->orWhere(fn (Builder $inner) => $inner->where('is_for', 'ot')->whereIn('member_id', $aboUserIds));
|
||
});
|
||
}
|
||
}
|