diff --git a/app/Models/UserSalesVolume.php b/app/Models/UserSalesVolume.php index 5db66347..19bc03ed 100644 --- a/app/Models/UserSalesVolume.php +++ b/app/Models/UserSalesVolume.php @@ -34,7 +34,7 @@ use Illuminate\Database\Eloquent\Model; * @property int|null $user_invoice_id * @property int|null $month_shop_points * @property float|null $month_shop_total_net - * @property-read \App\Models\UserInvoice|null $user_invoice + * @property-read UserInvoice|null $user_invoice * * @method static \Illuminate\Database\Eloquent\Builder|UserSalesVolume newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|UserSalesVolume newQuery() @@ -70,8 +70,10 @@ use Illuminate\Database\Eloquent\Model; * @method static \Illuminate\Database\Eloquent\Builder|UserSalesVolume whereMonthTPPoints($value) * * @property int|null $status_turnover + * @property int|null $storno_origin_status * * @method static \Illuminate\Database\Eloquent\Builder|UserSalesVolume whereStatusTurnover($value) + * @method static \Illuminate\Database\Eloquent\Builder|UserSalesVolume whereStornoOriginStatus($value) * * @mixin \Eloquent */ @@ -91,6 +93,7 @@ class UserSalesVolume extends Model 'month_shop_points' => 'float', 'status_points' => 'int', 'status_turnover' => 'int', + 'storno_origin_status' => 'int', 'total_net' => 'float', 'month_total_net' => 'float', 'month_shop_total_net' => 'float', @@ -115,6 +118,7 @@ class UserSalesVolume extends Model 'month_shop_points', 'status_points', 'status_turnover', + 'storno_origin_status', 'total_net', 'month_total_net', 'month_shop_total_net', @@ -247,6 +251,32 @@ class UserSalesVolume extends Model return isset(self::$statusTypes[$this->status]) ? __('payment.'.self::$statusTypes[$this->status]) : ''; } + /** + * Gehoert die Buchung zu einer Abo-Lieferung? + * + * Setzt eine geladene `shopping_order`-Relation voraus — jede Liste, die das + * auswertet, laedt sie bereits eager mit. + */ + public function isAboOrder(): bool + { + return (bool) $this->shopping_order?->is_abo; + } + + /** + * Art der Buchung, bei Abo-Lieferungen mit dem Abo-Zusatz dahinter + * ("Shopbestellung (Abo)") — Kundenwunsch 2026-09-08. + */ + public function getStatusTypeWithAbo(): string + { + $type = (string) $this->getStatusType(); + + if (! $this->isAboOrder()) { + return $type; + } + + return trim($type.' '.__('abo.abo_order_short')); + } + public static function getTransStatusType() { $ret = []; diff --git a/app/Services/Backoffice/PersonalStatisticsService.php b/app/Services/Backoffice/PersonalStatisticsService.php index a597075f..a6183f8a 100644 --- a/app/Services/Backoffice/PersonalStatisticsService.php +++ b/app/Services/Backoffice/PersonalStatisticsService.php @@ -31,6 +31,10 @@ class PersonalStatisticsService ->where('user_id', $user->id) ->where('month', $month) ->where('year', $year) + // Dieselbe Abgrenzung wie die Team-Auswertung: nicht zugeordnete (0) und + // pending (3) Buchungen zaehlen nirgends mit, sonst weicht die persoenliche + // Summe von der gemeinsamen Punktebasis ab. + ->whereIn('status', SalesVolumePoints::countableStatuses()) ->orderBy('id') ->get(); @@ -78,6 +82,37 @@ class PersonalStatisticsService return $order->is_abo ? 'abo' : 'shop'; } + if ($status === 6) { + return $this->stornoGroupKey($volume, $order); + } + + return 'other'; + } + + /** + * Ein Storno gehoert in das Segment seiner Ursprungsbuchung — sonst steht das + * Minus unter "Sonstige", waehrend der stornierte Umsatz weiter im Shop- oder + * Abo-Segment ausgewiesen wird. + * + * Altzeilen ohne festgehaltene Ursprungsart bleiben unter "Sonstige": ihre + * Herkunft ist nicht rekonstruierbar. + */ + private function stornoGroupKey(UserSalesVolume $volume, mixed $order): string + { + $origin = $volume->storno_origin_status; + + if ($origin === null) { + return 'other'; + } + + if ((int) $origin === 1) { + return 'own'; + } + + if ((int) $origin === 2 && $order) { + return $order->is_abo ? 'abo' : 'shop'; + } + return 'other'; } @@ -108,10 +143,11 @@ class PersonalStatisticsService 'points' => (float) $volume->points, 'total_net' => (float) $volume->total_net, 'status' => (int) $volume->status, - 'status_label' => $volume->getStatusType(), + 'status_label' => $volume->getStatusTypeWithAbo(), 'status_color' => $volume->getStatusColor(), 'order_id' => $order?->id, 'order_type' => $order ? ((int) $volume->status === 1 ? 'own' : 'shop') : null, + 'is_abo' => $volume->isAboOrder(), 'customer_name' => $customer ? trim(($customer->billing_firstname ?? '').' '.($customer->billing_lastname ?? '')) : null, 'customer_email' => $customer?->billing_email, 'info' => $volume->info, diff --git a/app/Services/Backoffice/SalesVolumePoints.php b/app/Services/Backoffice/SalesVolumePoints.php index 0c3e1463..351882ef 100644 --- a/app/Services/Backoffice/SalesVolumePoints.php +++ b/app/Services/Backoffice/SalesVolumePoints.php @@ -17,27 +17,64 @@ use Illuminate\Support\Collection; * `points`/`total_net`-Spalte summiert und nach `status` gefiltert – analog zu * {@see SalesPointsVolume::reCalculateSalesPointsVolume()}. * - * Status-Zuordnung: - * - Eigenpunkte (KP): status 1, 4, 5, 6 - * - Shop-/Kundenpunkte: status 2 (sowie status 6 mit status_turnover = 2) + * Status-Zuordnung (überschneidungsfrei — jede Zeile zählt genau einmal): + * - Eigenpunkte (KP): status 1, 4, 5 sowie Stornos (6), die KEINE Shop-Bestellung + * betreffen + * - Shop-/Kundenpunkte: status 2 sowie Stornos (6) einer Shop-Bestellung * - status 0 (nicht zugeordnet) und 3 (pending) zählen nicht * + * Massgeblich ist bei Stornos die Ursprungsart in `storno_origin_status` — NICHT + * `status_turnover`, denn eine stornierte Gutschrift bucht ihre Punkte auf KP/TP + * und nur ihren Umsatz auf den Shop. Altzeilen ohne Ursprungsart bleiben bei den + * Eigenpunkten; abgeschlossene Monate ändern sich dadurch nicht. + * * Alle Spalten werden voll qualifiziert, da `shopping_orders` ebenfalls eine * `points`-Spalte besitzt (Join-Mehrdeutigkeit). */ class SalesVolumePoints { - private const OWN_PREDICATE = 'user_sales_volumes.status IN (1, 4, 5, 6)'; + /** + * Welche Buchungsart wurde storniert? Entspricht der Auswertung in + * {@see SalesPointsVolume::reCalculateSalesPointsVolume()}, Case 6: bevorzugt + * die festgehaltene Ursprungsart, fuer Altzeilen ersatzweise die + * Umsatzzuordnung. COALESCE/CASE statt NOT(...), damit NULL-Spalten nicht zu + * einem unbestimmten Vergleich fuehren und die Zeile aus beiden Kategorien fliegt. + */ + private const STORNO_ORIGIN = 'COALESCE(user_sales_volumes.storno_origin_status, CASE WHEN user_sales_volumes.status_turnover = 2 THEN 2 ELSE 1 END)'; - private const SHOP_PREDICATE = '(user_sales_volumes.status = 2 OR (user_sales_volumes.status = 6 AND user_sales_volumes.status_turnover = 2))'; + private const OWN_PREDICATE = '(user_sales_volumes.status IN (1, 4, 5) OR (user_sales_volumes.status = 6 AND '.self::STORNO_ORIGIN.' <> 2))'; + + private const SHOP_PREDICATE = '(user_sales_volumes.status = 2 OR (user_sales_volumes.status = 6 AND '.self::STORNO_ORIGIN.' = 2))'; private const TURNOVER_PREDICATE = 'user_sales_volumes.status IN (1, 2, 4, 5, 6)'; + /** + * Die zaehlbaren Buchungsstatus als Liste — fuer Auswertungen, die nicht ueber + * die SQL-Ausdruecke hier gehen, aber dieselbe Abgrenzung brauchen. Status 0 + * (nicht zugeordnet) und 3 (pending) zaehlen nicht. + * + * @return int[] + */ + public static function countableStatuses(): array + { + return [1, 2, 4, 5, 6]; + } + public static function ownPoints(): string { return self::sumWhere(self::OWN_PREDICATE, 'user_sales_volumes.points'); } + /** + * Das Kundenpunkte-Prädikat ohne SUM-Hülle — für Auswertungen, die dieselben + * Zeilen brauchen, aber auf Positionsebene weiterrechnen (Aufteilung der + * Abo-Punkte in wiederkehrende und einmalig hinzugebuchte). + */ + public static function shopPredicate(): string + { + return self::SHOP_PREDICATE; + } + public static function externalPoints(): string { return self::sumWhere(self::SHOP_PREDICATE, 'user_sales_volumes.points'); diff --git a/app/Services/BusinessPlan/SalesPointsVolume.php b/app/Services/BusinessPlan/SalesPointsVolume.php index 9c9110e1..ab36201e 100644 --- a/app/Services/BusinessPlan/SalesPointsVolume.php +++ b/app/Services/BusinessPlan/SalesPointsVolume.php @@ -2,6 +2,7 @@ namespace App\Services\BusinessPlan; +use App\Models\Setting; use App\Models\ShoppingOrder; use App\Models\UserSalesVolume; use App\Services\Incentive\IncentiveTracker; @@ -11,6 +12,14 @@ use stdClass; class SalesPointsVolume { + /** + * Schalter fuer Stornos aus bereits abgeschlossenen Monaten (Kundenentscheidung + * 2026-09-09): standardmaessig `false` — ein abgeschlossener Monat ist fixiert, + * die Gutschriften sind erstellt, die Punkte bleiben unveraendert. Auf `true` + * gesetzt wird der Stornobetrag stattdessen vom laufenden Monat abgezogen. + */ + public const SETTING_STORNO_CURRENT_MONTH = 'storno-points-current-month'; + public static function changeSalesPointsVolumeUser(ShoppingOrder $shoppingOrder, $to_user_id) { @@ -103,10 +112,28 @@ class SalesPointsVolume $month_total_net += $userSalesVolume->total_net; break; case 6: // Storno - negative Punkte und Beträge - $month_points = self::add_KP_TP_Points($userSalesVolume, $month_points); + // Exaktes Spiegelbild der Ursprungsbuchung. Punkte und Umsatz folgen + // dabei UNTERSCHIEDLICHEN Regeln: + // - Punkte: nur eine stornierte Shop-Bestellung (Status 2) mindert die + // Shop-Punkte. Gutschrift (4), Eigenbestellung (1) und Registrierung (5) + // mindern KP/TP — genau wie ihre Ursprungszweige oben. + // - Umsatz: folgt `status_turnover`, wie im Gutschrift-Zweig (Case 4). + // Die Ursprungsart steht in `storno_origin_status` + // ({@see self::cancelSalesPointsVolume()}). Altzeilen ohne diese Angabe + // fallen auf `status_turnover` zurueck: abgeschlossene Monate bleiben so, + // wie sie abgerechnet wurden. + $originStatus = $userSalesVolume->storno_origin_status; + $pointsToShop = $originStatus !== null + ? (int) $originStatus === 2 + : $userSalesVolume->status_turnover === 2; + + if ($pointsToShop) { + $month_shop_points += $userSalesVolume->points; // bereits negativ + } else { + $month_points = self::add_KP_TP_Points($userSalesVolume, $month_points); + } if ($userSalesVolume->status_turnover === 2) { - $month_shop_points += $userSalesVolume->points; // bereits negativ $month_shop_total_net += $userSalesVolume->total_net; // bereits negativ } else { $month_total_net += $userSalesVolume->total_net; // bereits negativ @@ -269,21 +296,92 @@ class SalesPointsVolume } /** - * Erstellt einen Storno-Eintrag für eine stornierte Rechnung - * Negative Punkte werden dem aktuellen Monat zugeordnet + * Umsatzzuordnung der Stornobuchung: 2 = Shop-Umsatz, 1 = Eigenumsatz. + * + * Frueher wurde `status_turnover` unveraendert aus der Ursprungsbuchung + * uebernommen. Das Feld wird auf dem regulaeren Bestellweg aber gar nicht + * gesetzt (NULL), weshalb der Storno einer Kundenbestellung faelschlich als + * Eigenumsatz galt. Eine Gutschrift bringt ihre Zuordnung dagegen selbst mit + * und behaelt sie. + * + * Achtung: Dies steuert ausschliesslich den UMSATZ. Wohin die Punkte gehen, + * entscheidet die Ursprungsart in `storno_origin_status`. + */ + public static function stornoTurnoverFor(UserSalesVolume $original_sales_volume): int + { + if ((int) $original_sales_volume->status === 2) { // Shop-/Kundenbestellung + return 2; + } + + if ((int) $original_sales_volume->status === 4) { // Gutschrift: eigene Zuordnung + return (int) $original_sales_volume->status_turnover === 2 ? 2 : 1; + } + + return 1; + } + + /** + * Faellt die Ursprungsbuchung in den laufenden Monat, ist dieser noch nicht + * ausgewertet — der Storno darf ihn mindern. Ein abgeschlossener Monat ist + * dagegen fixiert (monatliche Auswertung gelaufen, Gutschriften erstellt). + */ + public static function isStornoMonthOpen(UserSalesVolume $original_sales_volume): bool + { + // now() statt date(): Pruefung und Buchung muessen derselben Uhr folgen, + // sonst haengt das Ergebnis in Tests an der echten Systemuhr. + return (int) $original_sales_volume->month === (int) now()->month + && (int) $original_sales_volume->year === (int) now()->year; + } + + /** + * Sollen Stornos aus abgeschlossenen Monaten ersatzweise den laufenden Monat + * mindern? {@see self::SETTING_STORNO_CURRENT_MONTH} + */ + public static function stornoDeductsInCurrentMonth(): bool + { + return Setting::getContentBySlug(self::SETTING_STORNO_CURRENT_MONTH) === true; + } + + /** + * Erstellt einen Storno-Eintrag für eine stornierte Rechnung. + * + * Stornoregel (Kundenentscheidung 2026-09-09): Nur solange der Monat der + * Ursprungsbuchung noch laeuft, wird die Punktekorrektur gebucht. Ist der + * Monat abgeschlossen, passiert nichts mehr — ein Minus im Folgemonat waere + * fuer den Berater nicht nachvollziehbar. Ausnahme ist der Schalter + * {@see self::SETTING_STORNO_CURRENT_MONTH}: steht er auf `true`, wird der + * Betrag doch vom laufenden Monat abgezogen (bisheriges Verhalten). + * + * Die Stornorechnung selbst wird davon nicht beruehrt; hier geht es + * ausschliesslich um die Punkte-/Umsatzbuchung. * * @param UserSalesVolume $original_sales_volume Der ursprüngliche Sales Volume Eintrag * @param int $cancellation_invoice_id Die ID der Stornorechnung - * @return UserSalesVolume + * @return UserSalesVolume|null null, wenn der Monat fixiert ist und nicht nachgebucht wird */ - public static function cancelSalesPointsVolume(UserSalesVolume $original_sales_volume, $cancellation_invoice_id) + public static function cancelSalesPointsVolume(UserSalesVolume $original_sales_volume, $cancellation_invoice_id): ?UserSalesVolume { - $month = date('m'); - $year = date('Y'); - $date = date('d.m.Y'); + if (! self::isStornoMonthOpen($original_sales_volume) && ! self::stornoDeductsInCurrentMonth()) { + \Log::info('Punktekorrektur für Stornorechnung übersprungen: Monat bereits abgeschlossen', [ + 'original_invoice_id' => $original_sales_volume->user_invoice_id, + 'cancellation_invoice_id' => $cancellation_invoice_id, + 'original_points' => $original_sales_volume->points, + 'user_id' => $original_sales_volume->user_id, + 'original_month' => $original_sales_volume->month, + 'original_year' => $original_sales_volume->year, + 'setting' => self::SETTING_STORNO_CURRENT_MONTH, + ]); + + return null; + } + + $bookedAt = now(); + $month = $bookedAt->month; + $year = $bookedAt->year; + $date = $bookedAt->format('d.m.Y'); $syslog = [ - date('d.m.Y H:i:s') => 'Stornorechnung erstellt - Punkte korrigiert (Original Invoice: #'.$original_sales_volume->user_invoice_id.')', + $bookedAt->format('d.m.Y H:i:s') => 'Stornorechnung erstellt - Punkte korrigiert (Original Invoice: #'.$original_sales_volume->user_invoice_id.')', ]; // Negativen Eintrag erstellen (alle Werte negativ) @@ -303,8 +401,9 @@ class SalesPointsVolume 'month_total_net' => 0, 'status' => 6, // Status 6 = cancelled/storniert 'status_points' => $original_sales_volume->status_points, - 'status_turnover' => $original_sales_volume->status_turnover, - 'message' => 'Storniert am '.date('d.m.Y'), + 'status_turnover' => self::stornoTurnoverFor($original_sales_volume), + 'storno_origin_status' => (int) $original_sales_volume->status, + 'message' => 'Storniert am '.$bookedAt->format('d.m.Y'), 'info' => 'Storno für Invoice #'.$original_sales_volume->user_invoice_id, 'syslog' => $syslog, ]); diff --git a/database/migrations/2026_09_09_140000_make_status_turnover_nullable_on_user_sales_volumes.php b/database/migrations/2026_09_09_140000_make_status_turnover_nullable_on_user_sales_volumes.php new file mode 100644 index 00000000..b59b32a9 --- /dev/null +++ b/database/migrations/2026_09_09_140000_make_status_turnover_nullable_on_user_sales_volumes.php @@ -0,0 +1,35 @@ +unsignedTinyInteger('status_turnover')->nullable()->default(null)->change(); + }); + } + + public function down(): void + { + // Bewusst ohne Rueckbau: NOT NULL liesse sich nicht wiederherstellen, + // solange die vorhandenen NULL-Zeilen existieren. + } +}; diff --git a/database/migrations/2026_09_10_090000_add_storno_origin_status_to_user_sales_volumes.php b/database/migrations/2026_09_10_090000_add_storno_origin_status_to_user_sales_volumes.php new file mode 100644 index 00000000..9934a722 --- /dev/null +++ b/database/migrations/2026_09_10_090000_add_storno_origin_status_to_user_sales_volumes.php @@ -0,0 +1,38 @@ +unsignedTinyInteger('storno_origin_status')->nullable()->after('status_turnover'); + } + }); + } + + public function down(): void + { + Schema::table('user_sales_volumes', function (Blueprint $table) { + if (Schema::hasColumn('user_sales_volumes', 'storno_origin_status')) { + $table->dropColumn('storno_origin_status'); + } + }); + } +}; diff --git a/resources/views/admin/settings/index.blade.php b/resources/views/admin/settings/index.blade.php index 5ecbb1cd..effec118 100755 --- a/resources/views/admin/settings/index.blade.php +++ b/resources/views/admin/settings/index.blade.php @@ -90,6 +90,16 @@ {{ Form::hidden('settings[day-exectute-business-structur][type]', 'int') }} +