Kundenabo-Vorschau: bereits abgerechnete Punkte fehlten in der Basis

Die Kachelreihe "Punkte aus meinen Kundenabos zum 5./10./20./25." startete bei
einer Basis von 0 und addierte nur die noch offenen Ausfuehrungen. Damit standen
die bereits vergangenen Termine auf null, obwohl im Monat laengst Lieferungen
gebucht waren, und die spaeteren Termine zeigten nur den Rest statt des
Monatsstands.

Berater 454 im September: vorher 0 / 0 / 131 / 319, jetzt 457 / 457 / 588 / 776.
Betrifft im September 86 Berater mit zusammen 15.936 Punkten — reine Anzeige,
gebucht und abgerechnet wurde korrekt.

Die Basis ist jetzt die im Monat aus den eigenen Kundenabos abgerechnete
Punktzahl, ueber dieselbe Abgrenzung wie die 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. Hinweistext in allen vier Sprachen
nachgezogen.

Nicht aus dem Audit-Paket: Die Null-Basis stammt aus der K9-Umsetzung vom 07.09.,
nicht aus der A13-Korrektur.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-09-11 07:36:13 +00:00
parent ff0860fb81
commit cb5664a483
7 changed files with 132 additions and 10 deletions

View file

@ -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<int, array{day: int, date: string, upcoming: float, total: float, is_past: bool}>}
*/
@ -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

View file

@ -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`.

View file

@ -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).',

View file

@ -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).',

View file

@ -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).',

View file

@ -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).',

View file

@ -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