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') }} +
+
+ + {{ Form::hidden('settings[storno-points-current-month][type]', 'bool') }} + Standard: aus. Storniert wird nur, solange der Monat der Ursprungsbestellung noch läuft — ist der Monat ausgewertet und die Gutschrift erstellt, bleiben die Punkte unverändert. Eingeschaltet wandert der Stornobetrag stattdessen als Minus in den laufenden Monat. Die Stornorechnung selbst wird in beiden Fällen erstellt. +
+
@@ -234,11 +244,12 @@ @php $selectedDhlProduct = \App\Models\Setting::getContentBySlug('dhl_product') ?: 'V01PAK'; + $selectedDhlProduct = \App\Services\DhlProductResolver::LEGACY_PRODUCT_CODE_ALIASES[$selectedDhlProduct] ?? $selectedDhlProduct; $selectedDhlProduct = $selectedDhlProduct === 'V62WP' ? 'V62KP' : $selectedDhlProduct; @endphp {{ Form::select('settings[dhl_product][val]', [ 'V01PAK' => 'V01PAK - DHL Paket National', - 'V53PAK' => 'V53PAK - DHL Paket International', + 'V53WPAK' => 'V53WPAK - DHL Paket International', 'V62KP' => 'V62KP - DHL Kleinpaket' ], $selectedDhlProduct, array('class'=>'form-control custom-select')) }} {{ Form::hidden('settings[dhl_product][type]', 'text') }} @@ -303,7 +314,9 @@ {{ Form::hidden('settings[dhl_international_countries][type]', 'object') }} - Aktivierte Länder verwenden im DHL-Modul automatisch V53PAK. Deutschland bleibt separat über V01PAK/V62KP geregelt. + Aktivierte Länder verwenden im DHL-Modul automatisch V53WPAK. Deutschland bleibt separat über V01PAK/V62KP geregelt. + Länder außerhalb der EU-Zollunion ({{ implode(', ', \App\Services\DhlProductResolver::CUSTOMS_REQUIRED_COUNTRIES) }}) bleiben gesperrt, + solange das Modul keine Zolldaten (ExportDocument/CN23) erzeugt – Sendungen dorthin bitte manuell anlegen.
@@ -332,9 +345,9 @@ {{ Form::hidden('settings[dhl_account_v07pak][type]', 'text') }}
- - {{ Form::text('settings[dhl_account_v53pak][val]', \App\Models\Setting::getContentBySlug('dhl_account_v53pak'), array('class'=>'form-control')) }} - {{ Form::hidden('settings[dhl_account_v53pak][type]', 'text') }} + + {{ Form::text('settings[dhl_account_v53wpak][val]', \App\Models\Setting::getContentBySlug('dhl_account_v53wpak') ?: \App\Models\Setting::getContentBySlug('dhl_account_v53pak'), array('class'=>'form-control')) }} + {{ Form::hidden('settings[dhl_account_v53wpak][type]', 'text') }}
diff --git a/tests/Feature/PointsAboHighlightTest.php b/tests/Feature/PointsAboHighlightTest.php new file mode 100644 index 00000000..4db3d499 --- /dev/null +++ b/tests/Feature/PointsAboHighlightTest.php @@ -0,0 +1,124 @@ + '/'.$name); +}); + +afterEach(function () { + Carbon::setTestNow(); +}); + +function pahUser(string $email): User +{ + return User::forceCreate([ + 'email' => $email, + 'password' => bcrypt('secret'), + 'lang' => 'de', + 'm_level' => 1, + 'admin' => 1, + 'active_date' => '2026-01-10', + 'payment_account' => now()->addYear(), + ]); +} + +function pahVolume(User $user, bool $isAbo, int $status = 1): UserSalesVolume +{ + $order = ShoppingOrder::create([ + 'shopping_user_id' => 0, + 'auth_user_id' => $user->id, + 'member_id' => $user->id, + 'country_id' => 1, + 'language' => 'de', + 'user_shop_id' => 1, + 'payment_for' => 6, + 'total' => 50.00, + 'subtotal' => 50.00, + 'points' => 45, + 'paid' => true, + 'shipped' => 0, + 'is_abo' => $isAbo, + 'mode' => 'live', + ]); + + return UserSalesVolume::forceCreate([ + 'user_id' => $user->id, + 'shopping_order_id' => $order->id, + 'month' => 9, + 'year' => 2026, + 'date' => '08.09.2026', + 'status' => $status, + 'points' => 45, + 'total_net' => 100, + ]); +} + +it('hebt Abo-Bestellungen in der Dashboard-Karte "Aktuelle Punkte" ab', function (): void { + // Kundenrueckmeldung 08.09.2026: In "Aktuelle Punkte" waren die Abos nicht + // hervorgehoben — die Kennzeichnung gab es bisher nur unter Mein Team > Punkte. + $user = pahUser('pah-dashboard@example.com'); + pahVolume($user, isAbo: true); + pahVolume($user, isAbo: false); + + $html = view('dashboard._points', ['user' => $user->fresh()])->render(); + + expect($html) + ->toContain('btn-xs btn-info') + ->toContain('btn-xs btn-primary') + ->toContain(e(__('abo.abo_order_short'))) + ->toContain(e(__('abo.points_order_legend'))) + // Kundenwunsch 09.09.2026: auch in der Spalte "Art" haengt der Abo-Zusatz dahinter + ->toContain(e(__('payment.advisor_order').' '.__('abo.abo_order_short'))); +}); + +it('liefert das Abo-Kennzeichen fuer die persoenliche Statistik mit', function (): void { + $user = pahUser('pah-personal@example.com'); + pahVolume($user, isAbo: true); + pahVolume($user, isAbo: false); + + $groups = app(PersonalStatisticsService::class)->monthly($user, 9, 2026)['groups']; + + $rows = collect($groups)->flatMap(fn (array $group) => $group['rows']); + + expect($rows->pluck('is_abo')->all())->toContain(true)->toContain(false); + + expect($rows->firstWhere('is_abo', true)['status_label']) + ->toContain(__('abo.abo_order_short')); + expect($rows->firstWhere('is_abo', false)['status_label']) + ->not->toContain(__('abo.abo_order_short')); +}); + +it('haengt den Abo-Zusatz nur bei Abo-Lieferungen an die Art', function (): void { + $user = pahUser('pah-model@example.com'); + $abo = pahVolume($user, isAbo: true)->fresh(['shopping_order']); + $shop = pahVolume($user, isAbo: false)->fresh(['shopping_order']); + + expect($abo->isAboOrder())->toBeTrue(); + expect($shop->isAboOrder())->toBeFalse(); + + expect($abo->getStatusTypeWithAbo())->toBe($abo->getStatusType().' '.__('abo.abo_order_short')); + expect($shop->getStatusTypeWithAbo())->toBe($shop->getStatusType()); +}); + +it('faerbt Abo-Bestellungen auch in "Meine Statistik" ein', function (): void { + // Die Seite haengt an layouts.layout-2 und laesst sich nicht isoliert rendern — + // geprueft wird deshalb die Verdrahtung von is_abo in der Blade-Quelle. + $view = file_get_contents(resource_path('views/user/backoffice/statistics/my.blade.php')); + + expect($view) + ->toContain("\$row['is_abo']") + ->toContain("'info' : 'primary'") + ->toContain("'info' : 'secondary'") + ->toContain("__('abo.points_order_legend')"); +}); diff --git a/tests/Feature/StornoClosedMonthTest.php b/tests/Feature/StornoClosedMonthTest.php new file mode 100644 index 00000000..1fdcb812 --- /dev/null +++ b/tests/Feature/StornoClosedMonthTest.php @@ -0,0 +1,122 @@ + 'storno-'.uniqid().'@example.test', + 'password' => bcrypt('secret'), + 'lang' => 'de', + 'admin' => 0, + 'm_level' => 1, + ]); +} + +function stornoVolume(User $user, int $month, int $year, float $points = 100.0): UserSalesVolume +{ + return UserSalesVolume::forceCreate([ + 'user_id' => $user->id, + 'user_invoice_id' => 4711, + 'month' => $month, + 'year' => $year, + 'date' => sprintf('%d-%02d-05', $year, $month), + 'points' => $points, + 'total_net' => $points, + 'status' => 2, + 'status_points' => 1, + 'status_turnover' => 1, + ]); +} + +it('bucht den Storno, solange der Monat der Ursprungsbuchung noch laeuft', function () { + $user = stornoUser(); + $original = stornoVolume($user, 9, 2026); + + $cancellation = SalesPointsVolume::cancelSalesPointsVolume($original, 9911); + + expect($cancellation)->not->toBeNull() + ->and((float) $cancellation->points)->toBe(-100.0) + ->and((int) $cancellation->status)->toBe(6) + ->and((int) $cancellation->month)->toBe(9) + ->and((int) $cancellation->year)->toBe(2026); + + expect(UserSalesVolume::where('user_id', $user->id)->where('status', 6)->count())->toBe(1); +}); + +it('laesst einen abgeschlossenen Monat unveraendert', function () { + $user = stornoUser(); + $original = stornoVolume($user, 8, 2026); + + $cancellation = SalesPointsVolume::cancelSalesPointsVolume($original, 9911); + + expect($cancellation)->toBeNull() + ->and(UserSalesVolume::where('user_id', $user->id)->where('status', 6)->count())->toBe(0); +}); + +it('behandelt denselben Monat im Vorjahr als abgeschlossen', function () { + $user = stornoUser(); + $original = stornoVolume($user, 9, 2025); + + expect(SalesPointsVolume::isStornoMonthOpen($original))->toBeFalse() + ->and(SalesPointsVolume::cancelSalesPointsVolume($original, 9911))->toBeNull(); +}); + +it('zieht abgeschlossene Monate im laufenden Monat ab, wenn der Schalter gesetzt ist', function () { + Setting::setContentBySlug(SalesPointsVolume::SETTING_STORNO_CURRENT_MONTH, 1, 'bool'); + + $user = stornoUser(); + $original = stornoVolume($user, 8, 2026); + + $cancellation = SalesPointsVolume::cancelSalesPointsVolume($original, 9911); + + expect($cancellation)->not->toBeNull() + ->and((float) $cancellation->points)->toBe(-100.0) + ->and((int) $cancellation->month)->toBe(9) + ->and((int) $cancellation->year)->toBe(2026); +}); + +it('ist ohne gesetzten Schalter ausgeschaltet', function () { + expect(SalesPointsVolume::stornoDeductsInCurrentMonth())->toBeFalse(); + + Setting::setContentBySlug(SalesPointsVolume::SETTING_STORNO_CURRENT_MONTH, 0, 'bool'); + expect(SalesPointsVolume::stornoDeductsInCurrentMonth())->toBeFalse(); + + Setting::setContentBySlug(SalesPointsVolume::SETTING_STORNO_CURRENT_MONTH, 1, 'bool'); + expect(SalesPointsVolume::stornoDeductsInCurrentMonth())->toBeTrue(); +}); + +it('folgt der Anwendungsuhr, nicht der Systemuhr', function () { + // Bewusst ein anderer Kalendermonat als der des Testlaufs: die Regel darf + // nicht davon abhaengen, welchen Monat der Server gerade zeigt. + Carbon::setTestNow('2027-03-15 09:00:00'); + + $user = stornoUser(); + $imLaufendenMonat = stornoVolume($user, 3, 2027); + $imVormonat = stornoVolume($user, 2, 2027); + + expect(SalesPointsVolume::isStornoMonthOpen($imLaufendenMonat))->toBeTrue() + ->and(SalesPointsVolume::isStornoMonthOpen($imVormonat))->toBeFalse(); + + $cancellation = SalesPointsVolume::cancelSalesPointsVolume($imLaufendenMonat, 9911); + + expect((int) $cancellation->month)->toBe(3) + ->and((int) $cancellation->year)->toBe(2027) + ->and($cancellation->message)->toBe('Storniert am 15.03.2027'); +}); diff --git a/tests/Feature/StornoPointsCategoryTest.php b/tests/Feature/StornoPointsCategoryTest.php new file mode 100644 index 00000000..8c33866a --- /dev/null +++ b/tests/Feature/StornoPointsCategoryTest.php @@ -0,0 +1,175 @@ + 'storno-cat-'.uniqid().'@example.test', + 'password' => bcrypt('secret'), + 'lang' => 'de', + 'admin' => 0, + 'm_level' => 1, + ]); +} + +/** + * Buchung wie auf dem regulaeren Bestellweg: `status_turnover` bleibt ungesetzt. + */ +function stornoCatVolume(User $user, int $status, float $points, ?int $statusTurnover = null): UserSalesVolume +{ + return UserSalesVolume::forceCreate([ + 'user_id' => $user->id, + 'user_invoice_id' => 4711, + 'month' => 9, + 'year' => 2026, + 'date' => '2026-09-05', + 'points' => $points, + 'total_net' => $points, + 'status' => $status, + 'status_points' => 1, + 'status_turnover' => $statusTurnover, + ]); +} + +it('kennzeichnet den Storno einer Shopbestellung als Shopgeschaeft', function () { + $user = stornoCatUser(); + $original = stornoCatVolume($user, 2, 100.0); + + $cancellation = SalesPointsVolume::cancelSalesPointsVolume($original, 9911); + + expect((int) $cancellation->status_turnover)->toBe(2); +}); + +it('kennzeichnet den Storno einer Beraterbestellung als Eigengeschaeft', function () { + $user = stornoCatUser(); + $original = stornoCatVolume($user, 1, 100.0); + + $cancellation = SalesPointsVolume::cancelSalesPointsVolume($original, 9911); + + expect((int) $cancellation->status_turnover)->toBe(1); +}); + +it('mindert beim Shop-Storno die Shop-Provisionsbasis statt der Eigenwerte', function () { + $user = stornoCatUser(); + $original = stornoCatVolume($user, 2, 100.0); + + SalesPointsVolume::cancelSalesPointsVolume($original, 9911); + + $last = UserSalesVolume::where('user_id', $user->id)->orderByDesc('id')->first(); + + expect((float) $last->month_shop_total_net)->toBe(0.0) + ->and((float) $last->month_shop_points)->toBe(0.0) + ->and((float) $last->month_total_net)->toBe(0.0) + ->and((float) $last->month_KP_points)->toBe(0.0); +}); + +it('mindert beim Eigen-Storno die Eigenwerte und laesst die Shop-Basis unberuehrt', function () { + $user = stornoCatUser(); + stornoCatVolume($user, 2, 50.0); + $original = stornoCatVolume($user, 1, 100.0); + + SalesPointsVolume::cancelSalesPointsVolume($original, 9911); + + $last = UserSalesVolume::where('user_id', $user->id)->orderByDesc('id')->first(); + + expect((float) $last->month_KP_points)->toBe(0.0) + ->and((float) $last->month_total_net)->toBe(0.0) + ->and((float) $last->month_shop_total_net)->toBe(50.0) + ->and((float) $last->month_shop_points)->toBe(50.0); +}); + +it('zaehlt jede Zeile genau einmal — Shop-Storno mindert die Kundenpunkte', function () { + $user = stornoCatUser(); + stornoCatVolume($user, 2, 100.0); + stornoCatVolume($user, 6, -100.0, 2); + + $rows = UserSalesVolume::query() + ->where('user_id', $user->id) + ->selectRaw(SalesVolumePoints::ownPoints().' as own') + ->selectRaw(SalesVolumePoints::externalPoints().' as external') + ->selectRaw(SalesVolumePoints::totalPoints().' as total') + ->first(); + + expect((float) $rows->own)->toBe(0.0) + ->and((float) $rows->external)->toBe(0.0) + ->and((float) $rows->total)->toBe(0.0); +}); + +it('laesst Altzeilen ohne Herkunft bei den Eigenpunkten', function () { + $user = stornoCatUser(); + stornoCatVolume($user, 2, 100.0); + stornoCatVolume($user, 6, -100.0); + + $rows = UserSalesVolume::query() + ->where('user_id', $user->id) + ->selectRaw(SalesVolumePoints::ownPoints().' as own') + ->selectRaw(SalesVolumePoints::externalPoints().' as external') + ->selectRaw(SalesVolumePoints::totalPoints().' as total') + ->first(); + + expect((float) $rows->own)->toBe(-100.0) + ->and((float) $rows->external)->toBe(100.0) + ->and((float) $rows->total)->toBe(0.0); +}); + +it('spiegelt den Storno einer Gutschrift mit Shop-Umsatz vollstaendig', function () { + $user = stornoCatUser(); + $credit = stornoCatVolume($user, 4, 100.0, 2); + SalesPointsVolume::reCalculateSalesPointsVolume($user->id, 9, 2026); + + $before = $credit->fresh(); + expect((float) $before->month_KP_points)->toBe(100.0) + ->and((float) $before->month_shop_total_net)->toBe(100.0) + ->and((float) $before->month_shop_points)->toBe(0.0); + + $cancellation = SalesPointsVolume::cancelSalesPointsVolume($credit, 9911)->fresh(); + + // Punkte gehen wie im Gutschrift-Zweig auf KP, der Umsatz auf den Shop. + expect((int) $cancellation->storno_origin_status)->toBe(4) + ->and((float) $cancellation->month_KP_points)->toBe(0.0) + ->and((float) $cancellation->month_shop_points)->toBe(0.0) + ->and((float) $cancellation->month_shop_total_net)->toBe(0.0) + ->and((float) $cancellation->month_total_net)->toBe(0.0); +}); + +it('haelt die Ursprungsart der Stornobuchung fest', function () { + $user = stornoCatUser(); + + expect((int) SalesPointsVolume::cancelSalesPointsVolume(stornoCatVolume($user, 2, 10.0), 1)->storno_origin_status)->toBe(2) + ->and((int) SalesPointsVolume::cancelSalesPointsVolume(stornoCatVolume($user, 1, 10.0), 2)->storno_origin_status)->toBe(1) + ->and((int) SalesPointsVolume::cancelSalesPointsVolume(stornoCatVolume($user, 5, 10.0), 3)->storno_origin_status)->toBe(5); +}); + +it('zaehlt den Storno einer Gutschrift bei den Eigenpunkten', function () { + $user = stornoCatUser(); + $credit = stornoCatVolume($user, 4, 100.0, 2); + SalesPointsVolume::cancelSalesPointsVolume($credit, 9911); + + $rows = UserSalesVolume::query() + ->where('user_id', $user->id) + ->selectRaw(SalesVolumePoints::ownPoints().' as own') + ->selectRaw(SalesVolumePoints::externalPoints().' as external') + ->selectRaw(SalesVolumePoints::totalPoints().' as total') + ->first(); + + expect((float) $rows->own)->toBe(0.0) + ->and((float) $rows->external)->toBe(0.0) + ->and((float) $rows->total)->toBe(0.0); +});