diff --git a/app/Services/Abo/PaylineForecastService.php b/app/Services/Abo/PaylineForecastService.php index 480f20b3..590e6dda 100644 --- a/app/Services/Abo/PaylineForecastService.php +++ b/app/Services/Abo/PaylineForecastService.php @@ -164,10 +164,12 @@ class PaylineForecastService * des Beraters (Kundenwunsch 2026-09-07: „wieviel Volumenpunkte habe ich am * 5. / 10. / 20. / 25. — nur mit meinen Kundenabos"). * - * Bewusst ohne das eigene Abo und ohne Payline: Basis ist 0, die Kacheln - * zeigen also die Punkte, die aus den eigenen Kundenabos bis zum jeweiligen - * Termin noch hereinkommen. Das ist ein anderer Zuschnitt als - * {@see forecastDays()}, wo eigene Abos in einer Team-Zahl mitlaufen. + * Bewusst ohne das eigene Abo und ohne Payline — aber MIT dem, was im Monat + * aus den Kundenabos schon abgerechnet ist. Die Basis war zuvor 0; damit + * standen die bereits gelieferten Termine auf null, obwohl die Punkte + * langst gebucht waren, und die spateren Termine zeigten nur den Rest statt + * des Monatsstands. Gleiche Lesart wie {@see forecastDays()} auf dem + * Dashboard: Stand heute plus das, was bis zum Termin noch hereinkommt. * * @return array{base: float, days: array} */ @@ -184,12 +186,34 @@ class PaylineForecastService ->with('user_abo_items.product') ->get(); + $base = $this->billedCustomerAboPoints($user, (int) $today->month, (int) $today->year); + return [ - 'base' => 0.0, - 'days' => $this->cumulateByDeliveryDay($abos, 0.0, $today), + 'base' => $base, + 'days' => $this->cumulateByDeliveryDay($abos, $base, $today), ]; } + /** + * Im Monat bereits abgerechnete Punkte aus den EIGENEN Kundenabos des + * Beraters — die Basis der Kundenabo-Terminvorschau. + * + * Nutzt dieselbe Abgrenzung wie die Backoffice-Statistik + * ({@see SalesVolumePoints::customerAboPoints()}): Shop-/Kundenbuchungen aus + * Abo-Bestellungen, Stornos vorzeichengetreu. Das eigene Abo des Beraters + * bleibt draussen, es laeuft nicht ueber dieses Praedikat. + */ + private function billedCustomerAboPoints(User $user, int $month, int $year): float + { + return (float) UserSalesVolume::query() + ->leftJoin('shopping_orders', 'shopping_orders.id', '=', 'user_sales_volumes.shopping_order_id') + ->where('user_sales_volumes.user_id', $user->id) + ->where('user_sales_volumes.month', $month) + ->where('user_sales_volumes.year', $year) + ->selectRaw(SalesVolumePoints::customerAboPoints().' as points') + ->value('points'); + } + /** * Offene Ausführungen auf die vier Liefertermine kumulieren: je Termin die * Punkte aller Abos, deren nächste Ausführung bis einschließlich zu diesem diff --git a/dev/2026-07-22/deploy-2026-09-10.md b/dev/2026-07-22/deploy-2026-09-10.md index c973f71a..63905fbf 100644 --- a/dev/2026-07-22/deploy-2026-09-10.md +++ b/dev/2026-07-22/deploy-2026-09-10.md @@ -76,3 +76,50 @@ Arbeitsbaum (DHL, Stripe/Payone, DATEV, `shipped_at`). Ebenfalls unverändert: die vorbestehenden Testfehler auf dem Branch (rund 42 SQLite-Schemafehler in Unit-Tests, 25 fehlende Übersetzungsschlüssel in `payment.php` für es und fr, verwendet in einer Admin-Ansicht). Gehören in ein eigenes Aufräumpaket. + +--- + +## Nachtrag 11.09.2026 — Kundenabo-Vorschau zeigte nur den Rest des Monats + +Beim Nachsehen auf Produktion aufgefallen (Berater 454): Auf `/user/abos/ot` stand die Kachelreihe +„Punkte aus meinen Kundenabos zum 5. / 10. / 20. / 25." für die bereits vergangenen Termine auf +**null**, obwohl im Monat längst Lieferungen gebucht waren. + +**Ursache.** Die Vorschau startete bei einer Basis von 0 und addierte nur die noch **offenen** +Ausführungen. Die im Monat bereits abgerechneten Kundenabo-Punkte fehlten vollständig. Die Kachel +beantwortete damit „was kommt noch" statt der Frage aus der Anforderung vom 07.09. („wieviel +Volumenpunkte habe ich am 5. / 10. / 20. / 25."). + +Berater 454, September 2026: + +| | vorher | jetzt | +| --- | --- | --- | +| 05.09. | 0 | 457 | +| 10.09. | 0 | 457 | +| 20.09. | 131 | 588 | +| 25.09. | 319 | 776 | + +Die 457 Punkte waren bereits abgerechnet (Lieferungen am 1., 5. und 10.), die 319 stehen noch aus. +Der Monatsstand aus Kundenabos beträgt 776. + +**Umfang.** Im September betrifft das **86 Berater** mit zusammen **15.936 Punkten**, die in dieser +Kachel nicht sichtbar waren. Nur eine Anzeige — es wurde nichts falsch gebucht oder abgerechnet. + +**Nicht aus dem Audit-Paket.** Die Null-Basis stammt aus der Umsetzung des Kundenwunschs vom 07.09. +(K9), nicht aus der A13-Korrektur; dort wurde in dieser Methode nur der Datumsfilter ersetzt. In die +Git-Historie ist sie erst mit `94e2b13` gekommen, weil der ganze Arbeitsstand vorher nicht committet +war. + +**Behebung.** Die Basis ist jetzt die im Monat aus den eigenen Kundenabos abgerechnete Punktzahl — +dieselbe Abgrenzung wie in der Backoffice-Statistik (`SalesVolumePoints::customerAboPoints()`), +Stornos vorzeichengetreu. Damit liest sich die Kachel wie die Prognose-Kachel auf dem Dashboard: +Stand heute plus das, was bis zum Termin noch hereinkommt. Der Hinweistext unter der Überschrift ist +in allen vier Sprachen nachgezogen. + +**Bewusste Grenze.** Ein vergangener Termin zeigt jetzt den **Monatsstand**, nicht den Stand genau an +jenem Tag — die Kachel am 05.09. enthält also auch die Lieferungen vom 10. Das ist dieselbe Lesart +wie auf dem Dashboard, und das Unterlabel sagt „Termin liegt bereits im Ist-Umsatz". Wer stattdessen +den echten Tagesverlauf sehen will, braucht eine Gruppierung der Buchungen nach Liefertag — das wäre +ein eigener Umbau. + +**Deployment:** nur Dateien, keine Migration, kein Skript. Danach `php artisan view:clear`. diff --git a/resources/lang/de/abo.php b/resources/lang/de/abo.php index edd2faeb..c0f938bb 100644 --- a/resources/lang/de/abo.php +++ b/resources/lang/de/abo.php @@ -53,7 +53,7 @@ return [ 'abo_order_short' => '(Abo)', 'points_order_legend' => 'Bestellungen: blau = eigene Bestellung · grau = Kundenbestellung · türkis mit „(Abo)" = Abo-Lieferung.', 'customer_abo_forecast_title' => 'Punkte aus meinen Kundenabos zum 5. / 10. / 20. / 25.', - 'customer_abo_forecast_hint' => 'Kumuliert: die Punkte aller eigenen Kundenabos, deren Lieferung bis zum jeweiligen Termin dieses Monats noch aussteht. Ohne dein eigenes Abo und ohne Team.', + 'customer_abo_forecast_hint' => 'Kumuliert: die in diesem Monat aus deinen Kundenabos bereits abgerechneten Punkte plus die Lieferungen, die bis zum jeweiligen Termin noch anstehen. Ohne dein eigenes Abo und ohne Team.', 'customer_abo_list_title' => 'Meine Kundenabos (:count)', 'customer_abo_list_search' => 'Kundenabos durchsuchen...', 'payline_none' => 'Noch keine Payline vorhanden (kein Provisionslevel erreicht).', diff --git a/resources/lang/en/abo.php b/resources/lang/en/abo.php index 911de7b0..cf5f7d82 100644 --- a/resources/lang/en/abo.php +++ b/resources/lang/en/abo.php @@ -179,7 +179,7 @@ return [ 'abo_order_short' => '(subscr.)', 'points_order_legend' => 'Orders: blue = your own order · grey = customer order · turquoise with "(subscr.)" = subscription delivery.', 'customer_abo_forecast_title' => 'Points from my customer subscriptions for the 5th / 10th / 20th / 25th', - 'customer_abo_forecast_hint' => 'Cumulative: the points of all your own customer subscriptions whose delivery is still due by the respective date this month. Without your own subscription and without your team.', + 'customer_abo_forecast_hint' => 'Cumulative: the points already billed from your customer subscriptions this month, plus the deliveries still due by the respective date. Without your own subscription and without your team.', 'customer_abo_list_title' => 'My customer subscriptions (:count)', 'customer_abo_list_search' => 'Search customer subscriptions...', 'payline_none' => 'No payline yet (no commission level reached).', diff --git a/resources/lang/es/abo.php b/resources/lang/es/abo.php index bcdf611a..1621a0db 100644 --- a/resources/lang/es/abo.php +++ b/resources/lang/es/abo.php @@ -162,7 +162,7 @@ return [ 'abo_order_short' => '(susc.)', 'points_order_legend' => 'Pedidos: azul = pedido propio · gris = pedido de cliente · turquesa con «(susc.)» = entrega de suscripción.', 'customer_abo_forecast_title' => 'Puntos de mis suscripciones de clientes para el 5 / 10 / 20 / 25', - 'customer_abo_forecast_hint' => 'Acumulado: los puntos de todas tus suscripciones de clientes cuya entrega aún está pendiente hasta la fecha correspondiente de este mes. Sin tu propia suscripción y sin el equipo.', + 'customer_abo_forecast_hint' => 'Acumulado: los puntos ya facturados este mes de tus suscripciones de clientes, más las entregas que siguen pendientes hasta la fecha correspondiente. Sin tu propia suscripción y sin el equipo.', 'customer_abo_list_title' => 'Mis suscripciones de clientes (:count)', 'customer_abo_list_search' => 'Buscar suscripciones de clientes...', 'payline_none' => 'Aún no hay payline (no se ha alcanzado ningún nivel de comisión).', diff --git a/resources/lang/fr/abo.php b/resources/lang/fr/abo.php index bcc07148..40e33a63 100644 --- a/resources/lang/fr/abo.php +++ b/resources/lang/fr/abo.php @@ -53,7 +53,7 @@ return [ 'abo_order_short' => '(abo)', 'points_order_legend' => 'Commandes : bleu = commande personnelle · gris = commande client · turquoise avec « (abo) » = livraison d’abonnement.', 'customer_abo_forecast_title' => 'Points de mes abonnements clients pour le 5 / 10 / 20 / 25', - 'customer_abo_forecast_hint' => 'Cumulé : les points de tous tes abonnements clients dont la livraison est encore à venir jusqu’à la date concernée ce mois-ci. Sans ton propre abonnement et sans l’équipe.', + 'customer_abo_forecast_hint' => 'Cumulé : les points déjà facturés ce mois-ci sur tes abonnements clients, plus les livraisons encore à venir jusqu’à la date concernée. Sans ton propre abonnement et sans l’équipe.', 'customer_abo_list_title' => 'Mes abonnements clients (:count)', 'customer_abo_list_search' => 'Rechercher des abonnements clients...', 'payline_none' => 'Pas encore de payline (aucun niveau de commission atteint).', diff --git a/tests/Feature/PaylineForecastServiceTest.php b/tests/Feature/PaylineForecastServiceTest.php index 31745987..6763eb2c 100644 --- a/tests/Feature/PaylineForecastServiceTest.php +++ b/tests/Feature/PaylineForecastServiceTest.php @@ -491,6 +491,57 @@ it('prognostiziert die Liefertage ausschliesslich aus den eigenen Kundenabos', f ->and($forecast['days'][3])->toMatchArray(['day' => 25, 'upcoming' => 75.0, 'total' => 75.0]); }); +it('rechnet die bereits abgerechneten Kundenabo-Punkte in die persoenliche Vorschau ein', function (): void { + // Die Kacheln beantworten "wieviel Volumenpunkte habe ich am 5./10./20./25.". + // Ohne die bereits gebuchten Lieferungen stehen die vergangenen Termine auf + // null, obwohl die Punkte laengst im Ist stecken. + Carbon::setTestNow(Carbon::parse('2026-07-11')); + + $berater = pflMakeUser(); + + $aboOrder = ShoppingOrder::forceCreate([ + 'shopping_user_id' => 0, 'auth_user_id' => $berater->id, 'country_id' => 0, + 'user_shop_id' => 0, 'is_abo' => true, 'paid' => true, + ]); + $shopOrder = ShoppingOrder::forceCreate([ + 'shopping_user_id' => 0, 'auth_user_id' => $berater->id, 'country_id' => 0, + 'user_shop_id' => 0, 'is_abo' => false, 'paid' => true, + ]); + + // Am 5. und 10. geliefert und gebucht. + UserSalesVolume::forceCreate(['user_id' => $berater->id, 'shopping_order_id' => $aboOrder->id, 'month' => 7, 'year' => 2026, 'points' => 40, 'status' => 2]); + UserSalesVolume::forceCreate(['user_id' => $berater->id, 'shopping_order_id' => $aboOrder->id, 'month' => 7, 'year' => 2026, 'points' => 60, 'status' => 2]); + // Einzelbestellung eines Kunden: kein Abo, gehoert nicht in diese Kachel. + UserSalesVolume::forceCreate(['user_id' => $berater->id, 'shopping_order_id' => $shopOrder->id, 'month' => 7, 'year' => 2026, 'points' => 500, 'status' => 2]); + // Anderer Monat. + UserSalesVolume::forceCreate(['user_id' => $berater->id, 'shopping_order_id' => $aboOrder->id, 'month' => 6, 'year' => 2026, 'points' => 900, 'status' => 2]); + + // Noch offen zum 20. + pflMakeAbo($berater->id, 'ot', '2026-07-20', 30); + + $forecast = app(PaylineForecastService::class)->personalCustomerAboForecast($berater); + + expect($forecast['base'])->toBe(100.0) + ->and($forecast['days'][0])->toMatchArray(['day' => 5, 'upcoming' => 0.0, 'total' => 100.0]) + ->and($forecast['days'][2])->toMatchArray(['day' => 20, 'upcoming' => 30.0, 'total' => 130.0]) + ->and($forecast['days'][3])->toMatchArray(['day' => 25, 'upcoming' => 30.0, 'total' => 130.0]); +}); + +it('zieht ein Storno von der Basis der persoenlichen Vorschau ab', function (): void { + Carbon::setTestNow(Carbon::parse('2026-07-11')); + + $berater = pflMakeUser(); + $aboOrder = ShoppingOrder::forceCreate([ + 'shopping_user_id' => 0, 'auth_user_id' => $berater->id, 'country_id' => 0, + 'user_shop_id' => 0, 'is_abo' => true, 'paid' => true, + ]); + + UserSalesVolume::forceCreate(['user_id' => $berater->id, 'shopping_order_id' => $aboOrder->id, 'month' => 7, 'year' => 2026, 'points' => 100, 'status' => 2]); + UserSalesVolume::forceCreate(['user_id' => $berater->id, 'shopping_order_id' => $aboOrder->id, 'month' => 7, 'year' => 2026, 'points' => -40, 'status' => 6, 'storno_origin_status' => 2]); + + expect(app(PaylineForecastService::class)->personalCustomerAboForecast($berater)['base'])->toBe(60.0); +}); + it('prognostiziert auch ohne bereits erreichte Qualifikationsstufe', function (): void { // A08: Wer im Monat noch keine Stufe qualifiziert hat, kann die erste durch // kommende eigene Kundenabo-Punkte trotzdem erreichen. Vorher lieferte die