diff --git a/app/Http/Controllers/User/AboController.php b/app/Http/Controllers/User/AboController.php index e6719c10..120c21bf 100644 --- a/app/Http/Controllers/User/AboController.php +++ b/app/Http/Controllers/User/AboController.php @@ -91,6 +91,11 @@ class AboController extends Controller ->orderBy('id', 'desc') ->get(); + // Vorschau und Monatsspalte gelten fuer den laufenden Monat; bei einem + // Filter auf einen anderen Monat waeren sie irrefuehrend. + $isCurrentMonthView = $selectedMonth === 0 + || ($selectedMonth === (int) now()->month && $selectedYear === (int) now()->year); + return view('user.abo.index', [ 'user_abos' => $user_abos, 'abo_payment_links' => $this->getAboPaymentLinks(), @@ -98,9 +103,18 @@ class AboController extends Controller 'isAdmin' => false, // Liefertag-Vorschau nur aus den eigenen Kundenabos // (Kundenwunsch 2026-09-07), und nur im laufenden Monat sinnvoll. - 'customerAboForecast' => $selectedMonth === 0 || ($selectedMonth === (int) now()->month && $selectedYear === (int) now()->year) + 'customerAboForecast' => $isCurrentMonthView ? app(PaylineForecastService::class)->personalCustomerAboForecast(\Auth::user()) : null, + // Je Abo die im laufenden Monat abgerechneten Punkte — inklusive + // Einmal-Artikel, die nach der Ausfuehrung aus dem Abo verschwinden. + // Ohne diese Spalte laesst sich die Vorschau oben nicht nachrechnen. + 'customerAboBilled' => $isCurrentMonthView + ? app(PaylineForecastService::class)->billedCustomerAboPointsByAbo(\Auth::user(), (int) now()->month, (int) now()->year) + : null, + 'customerAboBilledTotal' => $isCurrentMonthView + ? app(PaylineForecastService::class)->billedCustomerAboPoints(\Auth::user(), (int) now()->month, (int) now()->year) + : null, 'chartData' => AboHelper::getMonthlyAboCounts($baseQuery, $selectedYear, 'ot', \Auth::user()->id), 'chartYear' => $selectedYear, 'chartYears' => HTMLHelper::getYearRange(2026), diff --git a/app/Services/Abo/PaylineForecastService.php b/app/Services/Abo/PaylineForecastService.php index 590e6dda..eed209bd 100644 --- a/app/Services/Abo/PaylineForecastService.php +++ b/app/Services/Abo/PaylineForecastService.php @@ -164,14 +164,18 @@ 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 — 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. + * Ohne das eigene Abo und ohne Payline, aber MIT dem, was im Monat aus den + * Kundenabos schon abgerechnet ist — und zwar taggenau: jeder Termin zeigt + * den Stand, den er an diesem Tag tatsaechlich hatte, plus die bis dahin noch + * offenen Lieferungen. Damit laesst sich die Kachelreihe gegen die Liste + * darunter nachrechnen, was mit einer pauschalen Monatssumme als Basis nicht + * ging: ein vergangener Termin haette dann auch spaetere Lieferungen enthalten. * - * @return array{base: float, days: array} + * Anders als {@see forecastDays()} auf dem Dashboard, wo die Basis ein + * einzelner Tagesstand (Kundenumsatz + Payline-Punkte) ist und fuer alle + * Termine gleich bleibt. + * + * @return array{base: float, days: array} */ public function personalCustomerAboForecast(User $user): array { @@ -186,32 +190,90 @@ class PaylineForecastService ->with('user_abo_items.product') ->get(); - $base = $this->billedCustomerAboPoints($user, (int) $today->month, (int) $today->year); + $billedByDay = $this->billedCustomerAboPointsByDay($user, (int) $today->month, (int) $today->year); return [ - 'base' => $base, - 'days' => $this->cumulateByDeliveryDay($abos, $base, $today), + 'base' => (float) array_sum($billedByDay), + 'days' => $this->cumulateByDeliveryDay($abos, 0.0, $today, $billedByDay), ]; } /** - * Im Monat bereits abgerechnete Punkte aus den EIGENEN Kundenabos des - * Beraters — die Basis der Kundenabo-Terminvorschau. + * Grundabfrage fuer die im Monat abgerechneten Punkte aus den EIGENEN + * Kundenabos des Beraters. * - * Nutzt dieselbe Abgrenzung wie die Backoffice-Statistik - * ({@see SalesVolumePoints::customerAboPoints()}): Shop-/Kundenbuchungen aus + * Abgrenzung wie in der Backoffice-Statistik + * ({@see SalesVolumePoints::shopPredicate()}): Shop-/Kundenbuchungen aus * Abo-Bestellungen, Stornos vorzeichengetreu. Das eigene Abo des Beraters - * bleibt draussen, es laeuft nicht ueber dieses Praedikat. + * faellt heraus, es laeuft als Beraterbestellung (Status 1) nicht ueber + * dieses Praedikat. + * + * Gefiltert statt als SUM(CASE ...), damit dieselbe Menge sowohl je Liefertag + * als auch je Abo gruppiert werden kann — die Vorschau und die Liste darunter + * muessen auf dieselbe Summe kommen. */ - private function billedCustomerAboPoints(User $user, int $month, int $year): float + private function billedCustomerAboQuery(User $user, int $month, int $year): Builder { - return (float) UserSalesVolume::query() + return 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'); + ->where('shopping_orders.is_abo', 1) + ->whereRaw(SalesVolumePoints::shopPredicate()); + } + + /** + * Im Monat abgerechnete Kundenabo-Punkte, nach dem Kalendertag der Buchung + * gruppiert. Gruppiert wird in PHP: es sind wenige Zeilen je Berater und + * Monat, und eine Datumsfunktion in SQL waere zwischen MySQL und der + * SQLite-Testdatenbank nicht dieselbe. + * + * @return array Tag im Monat => Punkte + */ + private function billedCustomerAboPointsByDay(User $user, int $month, int $year): array + { + $byDay = []; + + foreach ($this->billedCustomerAboQuery($user, $month, $year) + ->select('user_sales_volumes.points', 'user_sales_volumes.created_at') + ->get() as $row) { + $day = $row->created_at ? (int) $row->created_at->day : 1; + $byDay[$day] = ($byDay[$day] ?? 0.0) + (float) $row->points; + } + + return $byDay; + } + + /** + * Im Monat abgerechnete Kundenabo-Punkte je Abo — fuer die Spalte „diesen + * Monat abgerechnet" in der Kundenabo-Liste. Enthaelt auch Einmal-Artikel und + * sonstige Zubuchungen der jeweiligen Lieferung; die werden nach der + * Ausfuehrung aus dem Abo entfernt und stehen deshalb nicht mehr im + * wiederkehrenden Aboinhalt. + * + * @return Collection user_abo_id => Punkte + */ + public function billedCustomerAboPointsByAbo(User $user, int $month, int $year): Collection + { + return $this->billedCustomerAboQuery($user, $month, $year) + ->join('user_abo_orders', 'user_abo_orders.shopping_order_id', '=', 'user_sales_volumes.shopping_order_id') + ->groupBy('user_abo_orders.user_abo_id') + ->select('user_abo_orders.user_abo_id') + ->selectRaw('COALESCE(SUM(user_sales_volumes.points), 0) as points') + ->pluck('points', 'user_abo_id') + ->map(fn ($points) => (float) $points); + } + + /** + * Monatssumme der abgerechneten Kundenabo-Punkte. Massgeblich fuer die + * Vorschau; {@see billedCustomerAboPointsByAbo()} kann davon abweichen, wenn + * eine Buchung keiner Abo-Ausfuehrung zugeordnet ist — die Liste weist die + * Differenz dann gesondert aus, damit die Addition aufgeht. + */ + public function billedCustomerAboPoints(User $user, int $month, int $year): float + { + return (float) array_sum($this->billedCustomerAboPointsByDay($user, $month, $year)); } /** @@ -223,7 +285,7 @@ class PaylineForecastService * @param Collection $abos * @return array */ - private function cumulateByDeliveryDay($abos, float $basePoints, Carbon $today): array + private function cumulateByDeliveryDay($abos, float $basePoints, Carbon $today, ?array $billedByDay = null): array { $pointsByDay = []; foreach ($abos as $abo) { @@ -231,8 +293,11 @@ class PaylineForecastService $pointsByDay[$day] = ($pointsByDay[$day] ?? 0.0) + (float) $abo->getTotalPoints(); } + $deliveryDays = UserAbo::$aboDeliveryDays; + $lastDeliveryDay = (int) max($deliveryDays); + $days = []; - foreach (UserAbo::$aboDeliveryDays as $deliveryDay) { + foreach ($deliveryDays as $deliveryDay) { $upcoming = 0.0; foreach ($pointsByDay as $day => $points) { if ($day <= $deliveryDay) { @@ -240,11 +305,26 @@ class PaylineForecastService } } + if ($billedByDay === null) { + $base = $basePoints; + } else { + $base = 0.0; + foreach ($billedByDay as $day => $points) { + // Buchungen nach dem letzten Liefertag (Nachzuegler, manuelle + // Gutschriften) haengen sich an den letzten Termin, damit die + // letzte Kachel immer den Monatsstand zeigt. + if ($day <= $deliveryDay || ($deliveryDay === $lastDeliveryDay && $day > $lastDeliveryDay)) { + $base += $points; + } + } + } + $days[] = [ 'day' => $deliveryDay, 'date' => $today->copy()->startOfMonth()->addDays($deliveryDay - 1)->format(\Util::formatDateDB()), + 'billed' => $base, 'upcoming' => $upcoming, - 'total' => $basePoints + $upcoming, + 'total' => $base + $upcoming, 'is_past' => $deliveryDay < (int) $today->day, ]; } diff --git a/dev/2026-07-22/deploy-2026-09-10.md b/dev/2026-07-22/deploy-2026-09-10.md index 63905fbf..caff82ba 100644 --- a/dev/2026-07-22/deploy-2026-09-10.md +++ b/dev/2026-07-22/deploy-2026-09-10.md @@ -110,16 +110,36 @@ Kachel nicht sichtbar waren. Nur eine Anzeige — es wurde nichts falsch gebucht 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. +**Behebung, Teil 1 — die Vorschau rechnet taggenau.** Basis jeder Kachel sind die bis zu diesem +Termin aus den eigenen Kundenabos abgerechneten Punkte, dazu die bis dahin noch offenen Lieferungen. +Abgrenzung wie in der Backoffice-Statistik (`SalesVolumePoints::shopPredicate()`), Stornos +vorzeichengetreu; das eigene Abo des Beraters bleibt draußen. Buchungen nach dem 25. (Nachzügler, +manuelle Gutschriften) hängen sich an die letzte Kachel, damit die immer den Monatsstand zeigt. -**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. +Für Beraterin 454 ergibt das **132 / 457 / 588 / 776** statt vorher 0 / 0 / 131 / 319. Jede Kachel +zeigt jetzt zusätzlich ihre Aufschlüsselung („457 abgerechnet + 131 offen"), damit man sie gegen die +Liste darunter nachrechnen kann. + +**Behebung, Teil 2 — die Liste zeigt, was tatsächlich gebucht wurde.** Die Spalte „Punkte" enthielt +nur den *wiederkehrenden* Abo-Inhalt. Was bei einer Lieferung zusätzlich dabei war — Einmal-Artikel +und sonstige Zubuchungen — wird nach der Ausführung aus dem Abo entfernt und tauchte deshalb nirgends +mehr auf. Bei Abo 266 waren das 43 Punkte (CBD Öl 27 + MSM PURE 16), bei Abo 418 rund 39. + +Neu daneben: **„Diesen Monat"** mit den je Abo tatsächlich gebuchten Punkten, bei Abweichung mit +einem Zusatz „+43 einmalig". Dazu eine Summenzeile unter der Tabelle. Kann eine Monatsbuchung keiner +gelisteten Abo-Zeile zugeordnet werden — gelöschtes Abo, Buchung ohne Ausführungsbezug —, weist die +Summenzeile den Rest gesondert aus, damit die Addition aufgeht. + +Damit stimmt die Rechnung durchgängig: + +``` +Summe Spalte "Diesen Monat" = 457 → Basis der letzten Kachel +Summe "Abo-Punkte" der Abos mit Termin 20./25. = 319 → offener Teil der letzten Kachel + --- +letzte Kachel 776 +``` + +Die Monatsspalte erscheint nur, solange kein anderer Monat gefiltert ist; für einen vergangenen Monat +wäre sie irreführend. **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 c0f938bb..a4f20bba 100644 --- a/resources/lang/de/abo.php +++ b/resources/lang/de/abo.php @@ -53,7 +53,17 @@ 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 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_forecast_hint' => 'Taggenau: je Termin die bis dahin aus deinen Kundenabos abgerechneten Punkte plus die Lieferungen, die bis dahin noch anstehen. Ohne dein eigenes Abo und ohne Team.', + 'customer_abo_forecast_split' => ':billed abgerechnet + :upcoming offen', + 'customer_abo_points_recurring' => 'Abo-Punkte', + 'customer_abo_points_recurring_hint' => 'Punkte des wiederkehrenden Abo-Inhalts — das, was bei der nächsten Lieferung zählt.', + 'customer_abo_points_billed' => 'Diesen Monat', + 'customer_abo_points_billed_hint' => 'Punkte, die dieses Abo in diesem Monat tatsächlich gebucht hat — einschließlich Einmal-Artikel und sonstiger Zubuchungen der Lieferung.', + 'customer_abo_points_extra' => 'einmalig', + 'customer_abo_points_extra_hint' => 'Differenz zum wiederkehrenden Abo-Inhalt: Einmal-Artikel oder sonstige Zubuchungen, die nach der Lieferung wieder aus dem Abo entfernt wurden.', + 'customer_abo_list_sum' => 'Summe', + 'customer_abo_list_sum_rest' => 'Weitere Abo-Buchungen des Monats (gelöschte Abos, Buchungen ohne Ausführungsbezug)', + 'customer_abo_list_sum_month' => 'Monat gesamt', '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 cf5f7d82..d45d8d85 100644 --- a/resources/lang/en/abo.php +++ b/resources/lang/en/abo.php @@ -179,7 +179,17 @@ 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 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_forecast_hint' => 'Day by day: for each date, the points billed from your customer subscriptions up to that day, plus the deliveries still due by then. Without your own subscription and without your team.', + 'customer_abo_forecast_split' => ':billed billed + :upcoming due', + 'customer_abo_points_recurring' => 'Subscription points', + 'customer_abo_points_recurring_hint' => 'Points of the recurring subscription content — what counts on the next delivery.', + 'customer_abo_points_billed' => 'This month', + 'customer_abo_points_billed_hint' => 'Points this subscription actually booked this month — including one-off items and other additions to the delivery.', + 'customer_abo_points_extra' => 'one-off', + 'customer_abo_points_extra_hint' => 'Difference from the recurring subscription content: one-off items or other additions that were removed from the subscription after delivery.', + 'customer_abo_list_sum' => 'Total', + 'customer_abo_list_sum_rest' => 'Other subscription bookings this month (deleted subscriptions, bookings without a delivery reference)', + 'customer_abo_list_sum_month' => 'Month total', '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 1621a0db..10c658c1 100644 --- a/resources/lang/es/abo.php +++ b/resources/lang/es/abo.php @@ -162,7 +162,17 @@ 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 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_forecast_hint' => 'Día a día: en cada fecha, los puntos facturados de tus suscripciones de clientes hasta ese día, más las entregas que siguen pendientes. Sin tu propia suscripción y sin el equipo.', + 'customer_abo_forecast_split' => ':billed facturados + :upcoming pendientes', + 'customer_abo_points_recurring' => 'Puntos de la suscripción', + 'customer_abo_points_recurring_hint' => 'Puntos del contenido recurrente de la suscripción: lo que cuenta en la próxima entrega.', + 'customer_abo_points_billed' => 'Este mes', + 'customer_abo_points_billed_hint' => 'Puntos que esta suscripción ha facturado realmente este mes, incluidos los artículos puntuales y otras adiciones a la entrega.', + 'customer_abo_points_extra' => 'puntual', + 'customer_abo_points_extra_hint' => 'Diferencia con el contenido recurrente de la suscripción: artículos puntuales u otras adiciones que se retiraron de la suscripción tras la entrega.', + 'customer_abo_list_sum' => 'Total', + 'customer_abo_list_sum_rest' => 'Otros movimientos de suscripción del mes (suscripciones eliminadas, movimientos sin referencia de entrega)', + 'customer_abo_list_sum_month' => 'Total del mes', '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 40e33a63..2bb01428 100644 --- a/resources/lang/fr/abo.php +++ b/resources/lang/fr/abo.php @@ -53,7 +53,17 @@ 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 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_forecast_hint' => 'Jour par jour : à chaque date, les points facturés sur tes abonnements clients jusqu’à ce jour, plus les livraisons encore à venir. Sans ton propre abonnement et sans l’équipe.', + 'customer_abo_forecast_split' => ':billed facturés + :upcoming à venir', + 'customer_abo_points_recurring' => 'Points de l’abonnement', + 'customer_abo_points_recurring_hint' => 'Points du contenu récurrent de l’abonnement — ce qui compte à la prochaine livraison.', + 'customer_abo_points_billed' => 'Ce mois-ci', + 'customer_abo_points_billed_hint' => 'Points réellement facturés par cet abonnement ce mois-ci, y compris les articles ponctuels et autres ajouts à la livraison.', + 'customer_abo_points_extra' => 'ponctuel', + 'customer_abo_points_extra_hint' => 'Écart par rapport au contenu récurrent de l’abonnement : articles ponctuels ou autres ajouts retirés de l’abonnement après la livraison.', + 'customer_abo_list_sum' => 'Total', + 'customer_abo_list_sum_rest' => 'Autres écritures d’abonnement du mois (abonnements supprimés, écritures sans référence de livraison)', + 'customer_abo_list_sum_month' => 'Total du mois', '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/resources/views/user/abo/_customer_abo_list.blade.php b/resources/views/user/abo/_customer_abo_list.blade.php index 76e09199..275b4356 100644 --- a/resources/views/user/abo/_customer_abo_list.blade.php +++ b/resources/views/user/abo/_customer_abo_list.blade.php @@ -3,7 +3,18 @@ nach ihrer Vorgabe: Kunde, Liefertag, Abo-Punkte, nächste Ausführung, Startdatum, Ausführungen. - Erwartet $listAbos (Collection) und $customerAboForecast|null. --}} + Erwartet $listAbos (Collection), $customerAboForecast|null sowie + $customerAboBilled|null (user_abo_id => im Monat abgerechnete Punkte) und + $customerAboBilledTotal|null. Die beiden letzten sind null, sobald auf einen + anderen Monat gefiltert wird — dann waere eine Monatsspalte irrefuehrend. --}} +@php + // Defensiv: die Teilansicht wird auch aus Kontexten eingebunden, die keine + // Monatswerte kennen (gefilterter Monat, Tests). + $customerAboForecast = $customerAboForecast ?? null; + $customerAboBilled = $customerAboBilled ?? null; + $customerAboBilledTotal = $customerAboBilledTotal ?? null; +@endphp + @if (! empty($customerAboForecast['days']))
@@ -25,6 +36,16 @@
{{ $forecastDay['is_past'] && $forecastDay['upcoming'] <= 0 ? __('home.forecast_done') : __('navigation.points') }}
+ @if ($forecastDay['upcoming'] != 0.0) + {{-- Aufschluesselung, damit die Kachel gegen die Liste + darunter nachrechenbar ist. --}} +
+ {{ __('abo.customer_abo_forecast_split', [ + 'billed' => \App\Services\Util::formatNumber($forecastDay['billed'] ?? 0.0), + 'upcoming' => \App\Services\Util::formatNumber($forecastDay['upcoming']), + ]) }} +
+ @endif
@@ -57,7 +78,10 @@ {{ __('tables.customer') }} {{ __('tables.abo_delivery_day') }} - {{ __('navigation.points') }} + {{ __('abo.customer_abo_points_recurring') }} + @if ($customerAboBilled !== null) + {{ __('abo.customer_abo_points_billed') }} + @endif {{ __('tables.next_date') }} {{ __('tables.start_date') }} {{ __('tables.abo_delivery') }} @@ -65,6 +89,17 @@ + @php + // Fuer die Summenzeile: was steht in der Liste, und bleibt vom + // Monatswert etwas uebrig, das keiner gelisteten Abo-Zeile zugeordnet + // werden kann (geloeschtes Abo, Buchung ohne Ausfuehrungsbezug)? + $billedShown = $customerAboBilled === null + ? 0.0 + : (float) $listAbos->sum(fn ($abo) => (float) ($customerAboBilled[$abo->id] ?? 0.0)); + $billedRest = $customerAboBilled === null + ? 0.0 + : round((float) $customerAboBilledTotal - $billedShown, 2); + @endphp @foreach ($listAbos as $listAbo) @@ -87,6 +122,26 @@ {{ \App\Services\HTMLHelper::getAboStrLang($listAbo->abo_interval) }} {{ $listAbo->getFormattedTotalPoints() }} + @if ($customerAboBilled !== null) + @php + $billedPoints = (float) ($customerAboBilled[$listAbo->id] ?? 0.0); + // Differenz zum wiederkehrenden Aboinhalt: Einmal-Artikel und + // sonstige Zubuchungen der Lieferung, die danach wieder aus + // dem Abo entfernt werden. Nur ausweisen, wenn das Abo diesen + // Monat ueberhaupt geliefert hat. + $extraPoints = $billedPoints != 0.0 + ? round($billedPoints - (float) $listAbo->getTotalPoints(), 2) + : 0.0; + @endphp + + {{ \App\Services\Util::formatNumber($billedPoints) }} + @if ($extraPoints != 0.0) +
+ {{ $extraPoints > 0 ? '+' : '' }}{{ \App\Services\Util::formatNumber($extraPoints) }} {{ __('abo.customer_abo_points_extra') }} +
+ @endif + + @endif {{ $listAbo->next_date }} {{ $listAbo->start_date }} {{ $listAbo->getCountPaidOrders() }} @@ -99,6 +154,28 @@ @endforeach + + + {{ __('abo.customer_abo_list_sum') }} + {{ \App\Services\Util::formatNumber($listAbos->sum(fn ($abo) => (float) $abo->getTotalPoints())) }} + @if ($customerAboBilled !== null) + {{ \App\Services\Util::formatNumber($billedShown) }} + @endif + + + @if ($customerAboBilled !== null && $billedRest != 0.0) + + {{ __('abo.customer_abo_list_sum_rest') }} + {{ \App\Services\Util::formatNumber($billedRest) }} + + + + {{ __('abo.customer_abo_list_sum_month') }} + {{ \App\Services\Util::formatNumber((float) $customerAboBilledTotal) }} + + + @endif + diff --git a/resources/views/user/abo/index.blade.php b/resources/views/user/abo/index.blade.php index 68cf1ea1..8ff29255 100644 --- a/resources/views/user/abo/index.blade.php +++ b/resources/views/user/abo/index.blade.php @@ -51,7 +51,9 @@
- @php($abo_payment_links = $abo_payment_links ?? collect()) + @php + $abo_payment_links = $abo_payment_links ?? collect(); + @endphp @if($view === 'ot' && $abo_payment_links->isNotEmpty()) @foreach($abo_payment_links as $paymentLink) @@ -140,7 +142,19 @@
@endif - @foreach($user_abos as $user_abo) + @php + // Kundenabos laufen ueber die sortierbare Liste; als Karte bleiben nur + // die Ausnahmefaelle mit offener Erstzahlung, weil dort Aktionsbuttons + // haengen (Kundenwunsch 2026-09-07). + $cardAbos = $view === 'ot' + ? collect($user_abos)->filter(fn ($abo) => $abo->isPendingInitialPayment()) + : collect($user_abos); + $listAbos = $view === 'ot' + ? collect($user_abos)->reject(fn ($abo) => $abo->isPendingInitialPayment())->values() + : collect(); + @endphp + + @foreach($cardAbos as $user_abo)
@if($user_abo->isPendingInitialPayment())
@@ -262,6 +276,15 @@ @endif
@endforeach + + @if($view === 'ot' && $listAbos->isNotEmpty()) + @include('user.abo._customer_abo_list', [ + 'listAbos' => $listAbos, + 'customerAboForecast' => $customerAboForecast ?? null, + 'customerAboBilled' => $customerAboBilled ?? null, + 'customerAboBilledTotal' => $customerAboBilledTotal ?? null, + ]) + @endif
@endsection diff --git a/tests/Feature/CustomerAboListViewTest.php b/tests/Feature/CustomerAboListViewTest.php index c61bbcf6..376cdcf8 100644 --- a/tests/Feature/CustomerAboListViewTest.php +++ b/tests/Feature/CustomerAboListViewTest.php @@ -53,10 +53,10 @@ it('rendert die Kundenabo-Liste mit allen gewuenschten Spalten', function (): vo $html = view('user.abo._customer_abo_list', [ 'listAbos' => collect([$abo]), 'customerAboForecast' => ['base' => 0.0, 'days' => [ - ['day' => 5, 'date' => '05.09.2026', 'upcoming' => 0.0, 'total' => 0.0, 'is_past' => true], - ['day' => 10, 'date' => '10.09.2026', 'upcoming' => 36.0, 'total' => 36.0, 'is_past' => false], - ['day' => 20, 'date' => '20.09.2026', 'upcoming' => 81.0, 'total' => 81.0, 'is_past' => false], - ['day' => 25, 'date' => '25.09.2026', 'upcoming' => 81.0, 'total' => 81.0, 'is_past' => false], + ['day' => 5, 'date' => '05.09.2026', 'billed' => 0.0, 'upcoming' => 0.0, 'total' => 0.0, 'is_past' => true], + ['day' => 10, 'date' => '10.09.2026', 'billed' => 0.0, 'upcoming' => 36.0, 'total' => 36.0, 'is_past' => false], + ['day' => 20, 'date' => '20.09.2026', 'billed' => 0.0, 'upcoming' => 81.0, 'total' => 81.0, 'is_past' => false], + ['day' => 25, 'date' => '25.09.2026', 'billed' => 0.0, 'upcoming' => 81.0, 'total' => 81.0, 'is_past' => false], ]], ])->render(); @@ -135,3 +135,82 @@ it('gibt unformatierte Sortierwerte aus, unabhaengig von der Sprache (A14)', fun ->toContain('data-sort-value="2026-04-05"') // Startdatum ->toContain('data-sort-value="20"'); // Liefertag })->with(['de', 'en']); + +it('zeigt die im Monat abgerechneten Punkte je Abo samt Summenzeile', function (): void { + // Die Liste muss sich gegen die Vorschau darueber nachrechnen lassen: Summe + // der Monatsspalte = Basis der letzten Kachel. + $user = User::forceCreate([ + 'email' => 'cal-billed@example.com', + 'password' => bcrypt('secret'), + 'lang' => 'de', + 'm_level' => 1, + 'payment_account' => now()->addYear(), + ]); + + $geliefert = calMakeAbo($user->id, '2026-04-05', '2026-10-05'); + $offen = calMakeAbo($user->id, '2026-05-20', '2026-09-20', 20); + + $html = view('user.abo._customer_abo_list', [ + 'listAbos' => collect([$geliefert, $offen]), + 'customerAboForecast' => null, + // 80 gebucht, aber nur 37 wiederkehrend → 43 einmalig. + 'customerAboBilled' => collect([$geliefert->id => 80.0]), + 'customerAboBilledTotal' => 80.0, + ])->render(); + + expect($html) + ->toContain(e(__('abo.customer_abo_points_billed'))) + ->toContain('data-sort-value="80"') + ->toContain(e(__('abo.customer_abo_points_extra'))) + ->toContain(e(__('abo.customer_abo_list_sum'))) + // Kein Rest → weder Rest- noch Monatszeile + ->not->toContain(e(__('abo.customer_abo_list_sum_rest'))); +}); + +it('weist einen nicht zuordenbaren Rest in der Summenzeile aus', function (): void { + // Ein im Monat geliefertes, inzwischen geloeschtes Abo steht nicht mehr in der + // Liste. Ohne die Restzeile ginge die Addition nicht auf. + $user = User::forceCreate([ + 'email' => 'cal-rest@example.com', + 'password' => bcrypt('secret'), + 'lang' => 'de', + 'm_level' => 1, + 'payment_account' => now()->addYear(), + ]); + + $abo = calMakeAbo($user->id, '2026-04-05', '2026-10-05'); + + $html = view('user.abo._customer_abo_list', [ + 'listAbos' => collect([$abo]), + 'customerAboForecast' => null, + 'customerAboBilled' => collect([$abo->id => 50.0]), + 'customerAboBilledTotal' => 78.0, + ])->render(); + + expect($html) + ->toContain(e(__('abo.customer_abo_list_sum_rest'))) + ->toContain(e(__('abo.customer_abo_list_sum_month'))) + ->toContain('28,00') // Rest + ->toContain('78,00'); // Monat gesamt +}); + +it('blendet die Monatsspalte aus, wenn auf einen anderen Monat gefiltert wird', function (): void { + $user = User::forceCreate([ + 'email' => 'cal-nobilled@example.com', + 'password' => bcrypt('secret'), + 'lang' => 'de', + 'm_level' => 1, + 'payment_account' => now()->addYear(), + ]); + + $html = view('user.abo._customer_abo_list', [ + 'listAbos' => collect([calMakeAbo($user->id, '2026-04-05', '2026-10-05')]), + 'customerAboForecast' => null, + 'customerAboBilled' => null, + 'customerAboBilledTotal' => null, + ])->render(); + + expect($html) + ->not->toContain(e(__('abo.customer_abo_points_billed'))) + ->toContain(e(__('abo.customer_abo_list_sum'))); +}); diff --git a/tests/Feature/PaylineForecastServiceTest.php b/tests/Feature/PaylineForecastServiceTest.php index 6763eb2c..18c57fc9 100644 --- a/tests/Feature/PaylineForecastServiceTest.php +++ b/tests/Feature/PaylineForecastServiceTest.php @@ -89,6 +89,19 @@ function pflMakeAbo(int $userId, string $isFor, string $nextDate, int $points, i return $abo->fresh(); } +function pflBookAboPoints(int $userId, int $orderId, float $points, string $createdAt, int $month = 7, int $year = 2026): UserSalesVolume +{ + return UserSalesVolume::forceCreate([ + 'user_id' => $userId, + 'shopping_order_id' => $orderId, + 'month' => $month, + 'year' => $year, + 'points' => $points, + 'status' => 2, + 'created_at' => $createdAt, + ]); +} + function pflSeedContext(User $user, array $memberIds, int $paylines = 2, ?int $month = null, ?int $year = null): void { $month ??= (int) now()->month; @@ -491,10 +504,10 @@ 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 { +it('rechnet die bereits abgerechneten Kundenabo-Punkte taggenau 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. + // Jeder Termin zeigt den Stand AN DIESEM TAG, sonst enthaelt die Kachel zum 5. + // auch schon die Lieferungen vom 10. und laesst sich nicht nachrechnen. Carbon::setTestNow(Carbon::parse('2026-07-11')); $berater = pflMakeUser(); @@ -508,13 +521,12 @@ it('rechnet die bereits abgerechneten Kundenabo-Punkte in die persoenliche Vorsc '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]); + pflBookAboPoints($berater->id, $aboOrder->id, 40, '2026-07-05 04:10:00'); + pflBookAboPoints($berater->id, $aboOrder->id, 60, '2026-07-10 04:10:00'); // 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]); + pflBookAboPoints($berater->id, $shopOrder->id, 500, '2026-07-10 04:10:00'); // Anderer Monat. - UserSalesVolume::forceCreate(['user_id' => $berater->id, 'shopping_order_id' => $aboOrder->id, 'month' => 6, 'year' => 2026, 'points' => 900, 'status' => 2]); + pflBookAboPoints($berater->id, $aboOrder->id, 900, '2026-06-10 04:10:00', 6, 2026); // Noch offen zum 20. pflMakeAbo($berater->id, 'ot', '2026-07-20', 30); @@ -522,9 +534,31 @@ it('rechnet die bereits abgerechneten Kundenabo-Punkte in die persoenliche Vorsc $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]); + ->and($forecast['days'][0])->toMatchArray(['day' => 5, 'billed' => 40.0, 'upcoming' => 0.0, 'total' => 40.0]) + ->and($forecast['days'][1])->toMatchArray(['day' => 10, 'billed' => 100.0, 'upcoming' => 0.0, 'total' => 100.0]) + ->and($forecast['days'][2])->toMatchArray(['day' => 20, 'billed' => 100.0, 'upcoming' => 30.0, 'total' => 130.0]) + ->and($forecast['days'][3])->toMatchArray(['day' => 25, 'billed' => 100.0, 'upcoming' => 30.0, 'total' => 130.0]); +}); + +it('haengt Buchungen nach dem letzten Liefertag an die letzte Kachel', function (): void { + // Nachzuegler und manuelle Gutschriften duerfen nicht aus der Monatssumme + // fallen — sonst zeigt die letzte Kachel weniger als den Monatsstand. + Carbon::setTestNow(Carbon::parse('2026-07-29')); + + $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, + ]); + + pflBookAboPoints($berater->id, $aboOrder->id, 10, '2026-07-05 04:10:00'); + pflBookAboPoints($berater->id, $aboOrder->id, 25, '2026-07-28 11:00:00'); + + $forecast = app(PaylineForecastService::class)->personalCustomerAboForecast($berater); + + expect($forecast['days'][0]['total'])->toBe(10.0) + ->and($forecast['days'][3]['total'])->toBe(35.0) + ->and($forecast['base'])->toBe(35.0); }); it('zieht ein Storno von der Basis der persoenlichen Vorschau ab', function (): void { @@ -536,10 +570,42 @@ it('zieht ein Storno von der Basis der persoenlichen Vorschau ab', function (): '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]); + pflBookAboPoints($berater->id, $aboOrder->id, 100, '2026-07-05 04:10:00'); + UserSalesVolume::forceCreate([ + 'user_id' => $berater->id, 'shopping_order_id' => $aboOrder->id, 'month' => 7, 'year' => 2026, + 'points' => -40, 'status' => 6, 'storno_origin_status' => 2, 'created_at' => '2026-07-08 09:00:00', + ]); - expect(app(PaylineForecastService::class)->personalCustomerAboForecast($berater)['base'])->toBe(60.0); + $forecast = app(PaylineForecastService::class)->personalCustomerAboForecast($berater); + + expect($forecast['base'])->toBe(60.0) + ->and($forecast['days'][1]['billed'])->toBe(60.0); +}); + +it('weist die abgerechneten Punkte je Abo aus, inklusive Einmal-Artikel', function (): void { + // Die Liste unter der Vorschau muss auf dieselbe Summe kommen. Einmal-Artikel + // werden nach der Ausfuehrung aus dem Abo entfernt, stehen also nicht mehr im + // wiederkehrenden Aboinhalt — gebucht wurden sie trotzdem. + Carbon::setTestNow(Carbon::parse('2026-07-11')); + + $berater = pflMakeUser(); + $abo = pflMakeAbo($berater->id, 'ot', '2026-08-05', 37); + + $aboOrder = ShoppingOrder::forceCreate([ + 'shopping_user_id' => 0, 'auth_user_id' => $berater->id, 'country_id' => 0, + 'user_shop_id' => 0, 'is_abo' => true, 'paid' => true, + ]); + $abo->user_abo_orders()->create(['shopping_order_id' => $aboOrder->id, 'status' => 1, 'paid' => true]); + + // Geliefert wurden 80: 37 wiederkehrend + 43 einmalig. + pflBookAboPoints($berater->id, $aboOrder->id, 80, '2026-07-05 04:10:00'); + + $svc = app(PaylineForecastService::class); + $byAbo = $svc->billedCustomerAboPointsByAbo($berater, 7, 2026); + + expect($byAbo[$abo->id])->toBe(80.0) + ->and($byAbo->sum())->toBe(80.0) + ->and($svc->billedCustomerAboPoints($berater, 7, 2026))->toBe(80.0); }); it('prognostiziert auch ohne bereits erreichte Qualifikationsstufe', function (): void {