From ef19dc2ced7c643a8e2788d4dfdb50d7fee843f4 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Mon, 14 Sep 2026 08:15:30 +0000 Subject: [PATCH] =?UTF-8?q?Absatzstatistik:=20Summen=20aus=20Bestellpositi?= =?UTF-8?q?onen,=20Kompensationsprodukte=20ausgewiesen,=20Extern-Bestellun?= =?UTF-8?q?gen=20nachgef=C3=BCllt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Stats\Sales: alle Summenzeilen (Angezeigt/Ausgeblendet/Gesamt/Rabatte/Netto) aus price_net × qty der Positionen statt aus subtotal_full/discount des Bestellkopfs; WooCommerce-Importe (txaction extern) hatten dort NULL und zählten seit 06/2026 mit 0 € (Aug 2026: 449 € statt 3.318 € angezeigt) - Kompensationsprodukte (comp > 0) bleiben in der Menge, neue Spalten „davon Kompensation“ (aktuell + Vorjahr) und Summenzeile 9993 - Gelöschte Produkte bleiben im Umsatz sichtbar (withTrashed, „(gelöscht)“) - WP-Import setzt subtotal_full und discount jetzt wie der Checkout - Migration füllt subtotal_full/discount bestehender Extern-Bestellungen nach - Excel-Export um die beiden Kompensationsspalten erweitert - Test tests/Feature/StatsSalesVolumeTest.php (Konsistenz, Filter, Jahr) Co-Authored-By: Claude Opus 5 (1M context) --- .../Api/ShoppingUserController.php | 4 + .../Controllers/Stats/SalesController.php | 80 ++-- app/Services/Stats/Sales.php | 386 ++++++++---------- ...ackfill_subtotal_full_on_extern_orders.php | 34 ++ .../views/admin/stats/salesvolume.blade.php | 7 +- tests/Feature/StatsSalesVolumeTest.php | 177 ++++++++ 6 files changed, 433 insertions(+), 255 deletions(-) create mode 100644 database/migrations/2026_09_14_100000_backfill_subtotal_full_on_extern_orders.php create mode 100644 tests/Feature/StatsSalesVolumeTest.php diff --git a/app/Http/Controllers/Api/ShoppingUserController.php b/app/Http/Controllers/Api/ShoppingUserController.php index e970191..14ec467 100755 --- a/app/Http/Controllers/Api/ShoppingUserController.php +++ b/app/Http/Controllers/Api/ShoppingUserController.php @@ -799,6 +799,10 @@ class ShoppingUserController extends Controller // 'user_shop_id' => auth()->user()->user_sponsor->shop->id, 'member_id' => $shopping_user->member_id, 'total' => Yard::instance('shopping')->total(2, '.', ''), + // Kopf-Felder wie im Checkout füllen, sonst bleiben sie NULL und + // die Absatzstatistik (Stats\Sales) zählt diese Bestellungen als 0 €. + 'subtotal_full' => Yard::instance('shopping')->subtotal(2, '.', '', false), + 'discount' => 0, 'subtotal' => Yard::instance('shopping')->subtotal(2, '.', ''), 'shipping' => Yard::instance('shopping')->shipping(2, '.', ','), 'shipping_net' => Yard::instance('shopping')->shippingNet(2, '.', ''), diff --git a/app/Http/Controllers/Stats/SalesController.php b/app/Http/Controllers/Stats/SalesController.php index 790dd1f..cfb95c3 100755 --- a/app/Http/Controllers/Stats/SalesController.php +++ b/app/Http/Controllers/Stats/SalesController.php @@ -1,26 +1,20 @@ HTMLHelper::getYearRange(2020), 'filter_products' => $this->serviceSales->setFilterProducts(), ]; + return view('admin.stats.salesvolume', $data); } - public function download() { $this->setFilterVars(); - if (Request::get('action') === "filter") { + if (Request::get('action') === 'filter') { // $data = Request::all(); return back(); } - if (Request::get('action') === "export") { + if (Request::get('action') === 'export') { $objects = $this->serviceSales->getObjects(); $columns = []; - $filename = "gs-absatzmengen-" . session('product_sales_vol_filter_month') . '_' . session('product_sales_vol_filter_year') . "-export"; - $headers = array( + $filename = 'gs-absatzmengen-'.session('product_sales_vol_filter_month').'_'.session('product_sales_vol_filter_year').'-export'; + $headers = [ '#', 'Produkt', 'Artikelnummer', 'Menge', + 'davon Kompensation', 'Gesamt Netto in EURO', 'Vorjahr Menge', + 'Vorjahr davon Kompensation', 'Vorjahr Gesamt Netto in EURO', 'Einzelrabatt', 'Einzelrabatt %', 'VP Einzelrabatt %', - - ); + ]; if ($objects) { foreach ($objects as $key => $obj) { - $columns[] = array( - 'id' => $key, - 'name' => $obj['name'], - 'number' => $obj['number'], - 'qty' => $obj['qty'], - 'total' => $obj['total'], - 'pre_qty' => $obj['pre_qty'], - 'pre_total' => $obj['pre_total'], + $columns[] = [ + 'id' => $key, + 'name' => $obj['name'], + 'number' => $obj['number'], + 'qty' => $obj['qty'], + 'comp_qty' => $obj['comp_qty'], + 'total' => $obj['total'], + 'pre_qty' => $obj['pre_qty'], + 'pre_comp_qty' => $obj['pre_comp_qty'], + 'pre_total' => $obj['pre_total'], 'single_commission' => $obj['single_commission'], - 'value_commission' => $obj['value_commission'], + 'value_commission' => $obj['value_commission'], 'partner_commission' => $obj['partner_commission'], - ); + ]; } } - return Excel::download(new ExcelExport($columns, $headers), $filename . '.xls'); + + return Excel::download(new ExcelExport($columns, $headers), $filename.'.xls'); } } private function setFilterVars() { - if (!session('product_sales_vol_filter_month')) { + if (! session('product_sales_vol_filter_month')) { session(['product_sales_vol_filter_month' => intval(date('m'))]); } - if (!session('product_sales_vol_filter_year')) { + if (! session('product_sales_vol_filter_year')) { session(['product_sales_vol_filter_year' => intval(date('Y'))]); } - if (!session('product_sales_vol_filter_products')) { + if (! session('product_sales_vol_filter_products')) { session(['product_sales_vol_filter_products' => []]); } if (Request::get('product_sales_vol_filter_month')) { @@ -118,14 +116,6 @@ class SalesController extends Controller ); } - - - - - - - - public function datatable() { @@ -158,7 +148,7 @@ class SalesController extends Controller $counter = 0; foreach($ShoppingOrders as $ShoppingOrder){ foreach($ShoppingOrder->shopping_order_items as $shopping_order_item){ - + if($shopping_order_item->product){ if($shopping_order_item->product->id === 122){ //dump($shopping_order_item->qty); @@ -168,8 +158,8 @@ class SalesController extends Controller $objects[$shopping_order_item->product->id]['value'] = $value; }else{ $objects[$shopping_order_item->product->id] = [ - 'name' => $shopping_order_item->product->name, - 'number' => $shopping_order_item->product->number, + 'name' => $shopping_order_item->product->name, + 'number' => $shopping_order_item->product->number, 'value' => $shopping_order_item->qty ]; } diff --git a/app/Services/Stats/Sales.php b/app/Services/Stats/Sales.php index 0eefc3b..a25773b 100644 --- a/app/Services/Stats/Sales.php +++ b/app/Services/Stats/Sales.php @@ -1,18 +1,43 @@ 0, Preis 0 €) fließen in die + * Absatzmenge ein und werden zusätzlich separat als comp_qty ausgewiesen. + */ class Sales { + public const ROW_SHOWN = 9990; + + public const ROW_HIDDEN = 9991; + + public const ROW_TOTAL = 9992; + + public const ROW_COMP = 9993; + + public const ROW_DISCOUNT = 9998; + + public const ROW_NET = 9999; + private $month; + private $year; + private $products; + private $objects; - public function __construct() { @@ -22,248 +47,191 @@ class Sales $this->objects = []; } - public function setFilterVars($month = null, $year = null, $products = null){ + public function setFilterVars($month = null, $year = null, $products = null) + { $this->month = $month ? $month : intval(date('m')); $this->year = $year ? $year : intval(date('Y')); - $this->products = $products; + $this->products = is_array($products) ? $products : []; } - public function setFilterProducts(){ - - $ShoppingOrders = $this->getShoppingOrdersBy($this->month, $this->year); + public function setFilterProducts() + { $products = []; - foreach($ShoppingOrders as $ShoppingOrder){ - foreach($ShoppingOrder->shopping_order_items as $shopping_order_item){ - if($shopping_order_item->product && !$shopping_order_item->product->exclude_stats_sales && !isset($products[$shopping_order_item->product->id])){ - $products[$shopping_order_item->product->id] = $shopping_order_item->product->name.' # '. - ($shopping_order_item->product->single_commission ? $shopping_order_item->product->value_commission.' / '.$shopping_order_item->product->partner_commission : 'Staffelrabatt'); + foreach ($this->getShoppingOrdersBy($this->month, $this->year) as $ShoppingOrder) { + foreach ($ShoppingOrder->shopping_order_items as $shopping_order_item) { + $product = $shopping_order_item->product; + if ($product && ! $product->exclude_stats_sales && ! isset($products[$product->id])) { + $products[$product->id] = $this->productName($product).' # '. + ($product->single_commission ? $product->value_commission.' / '.$product->partner_commission : 'Staffelrabatt'); } } } + return $products; } - private function getShoppingOrdersBy($month, $year){ - if($month == '13'){ //all the year + private function getShoppingOrdersBy($month, $year) + { + if ($month == '13') { // all the year $date_start = Carbon::parse('01.01.'.$year)->format('Y-m-d H:i:s'); $date_end = Carbon::parse('31.12.'.$year)->endOfMonth()->format('Y-m-d H:i:s'); - }else{ + } else { $date_start = Carbon::parse('01.'.$month.'.'.$year)->format('Y-m-d H:i:s'); $date_end = Carbon::parse('01.'.$month.'.'.$year)->endOfMonth()->format('Y-m-d H:i:s'); - } - return ShoppingOrder::where('paid', 1)->where('mode', 'live')->whereBetween('created_at', [$date_start, $date_end])->get(); + } + + // Gelöschte Produkte bleiben im Umsatz sichtbar (withTrashed), sonst + // fielen deren Positionen weder in „Angezeigt“ noch in „Ausgeblendet“. + return ShoppingOrder::where('paid', 1)->where('mode', 'live') + ->whereBetween('created_at', [$date_start, $date_end]) + ->with(['shopping_order_items.product' => fn ($q) => $q->withTrashed()]) + ->get(); } - public function getCollection(){ - + public function getCollection() + { $this->getObjects(); $collection = collect(); - foreach($this->objects as $key => $obj){ + foreach ($this->objects as $key => $obj) { $collection->push([ - 'id' => $key, - 'name' => $obj['name'], - 'number' => $obj['number'], - 'qty' => $obj['qty'], - 'total' => $obj['total'], - 'pre_qty' => $obj['pre_qty'], - 'pre_total' => $obj['pre_total'], - 'single_commission' => $obj['single_commission'], - 'value_commission' => $obj['value_commission'], - 'partner_commission' => $obj['partner_commission'], - + 'id' => $key, + 'name' => $obj['name'], + 'number' => $obj['number'], + 'qty' => $obj['qty'], + 'comp_qty' => $obj['comp_qty'], + 'total' => $obj['total'], + 'pre_qty' => $obj['pre_qty'], + 'pre_comp_qty' => $obj['pre_comp_qty'], + 'pre_total' => $obj['pre_total'], + 'single_commission' => $obj['single_commission'], + 'value_commission' => $obj['value_commission'], + 'partner_commission' => $obj['partner_commission'], ]); } + return $collection; } - public function getObjects(){ - $this->readObjects(); - $this->readObjectsPreview(); + public function getObjects() + { + $current = $this->aggregate($this->month, $this->year); + $previous = $this->aggregate($this->month, $this->year - 1); + + $this->objects = []; + foreach ($current['products'] + $previous['products'] as $id => $row) { + $cur = $current['products'][$id] ?? null; + $pre = $previous['products'][$id] ?? null; + $this->objects[$id] = [ + 'name' => $row['name'], + 'number' => $row['number'], + 'qty' => $cur['qty'] ?? 0, + 'comp_qty' => $cur['comp_qty'] ?? 0, + 'total' => formatNumber(round($cur['total'] ?? 0, 2)), + 'pre_qty' => $pre['qty'] ?? 0, + 'pre_comp_qty' => $pre['comp_qty'] ?? 0, + 'pre_total' => formatNumber(round($pre['total'] ?? 0, 2)), + 'single_commission' => $row['single_commission'], + 'value_commission' => $row['value_commission'], + 'partner_commission' => $row['partner_commission'], + ]; + } + + $summary = [ + self::ROW_SHOWN => 'Angezeigter Umsatz netto €', + self::ROW_HIDDEN => 'Ausgeblendeter Umsatz netto €', + self::ROW_TOTAL => 'Gesamter Umsatz netto € (alle Verkäufe)', + self::ROW_COMP => 'davon Kompensationsprodukte Stück (in den Mengen enthalten, 0 € Umsatz)', + self::ROW_DISCOUNT => 'Gesamte Rabatte netto € (alle Verkäufe)', + self::ROW_NET => 'Gesamt netto € (alle Verkäufe)', + ]; + foreach ($summary as $key => $name) { + $this->objects[$key] = [ + 'name' => $name, + 'number' => '', + 'qty' => $key === self::ROW_COMP ? $current['comp_qty'] : '', + 'comp_qty' => '', + 'total' => $key === self::ROW_COMP ? '' : formatNumber(round($current['sums'][$key], 2)), + 'pre_qty' => $key === self::ROW_COMP ? $previous['comp_qty'] : 0, + 'pre_comp_qty' => '', + 'pre_total' => $key === self::ROW_COMP ? '' : formatNumber(round($previous['sums'][$key], 2)), + 'single_commission' => '', + 'value_commission' => '', + 'partner_commission' => '', + ]; + } + return $this->objects; } - - private function readObjects() + + /** + * Produktzeilen und Summen eines Zeitraums, unformatiert. + * + * @return array{products: array, sums: array, comp_qty: int} + */ + private function aggregate($month, $year): array { - $shoppingOrders = $this->getShoppingOrdersBy($this->month, $this->year); - $this->objects = []; - - $subtotal_full = 0; // gesamtumsatz - $subtotal = 0; // gesamtumsatz ohne rabatte - $discount = 0; // gesamtrabatte - $subtotal_hide = 0; // ausgeschlossene Produkte + $products = []; + $shown = 0.0; + $hidden = 0.0; + $discount = 0.0; + $compQty = 0; - foreach($shoppingOrders as $ShoppingOrder){ - $subtotal_full += $ShoppingOrder->subtotal_full; - $subtotal += $ShoppingOrder->subtotal; - $discount += $ShoppingOrder->discount; + foreach ($this->getShoppingOrdersBy($month, $year) as $ShoppingOrder) { + $discount += (float) $ShoppingOrder->discount; - foreach($ShoppingOrder->shopping_order_items as $shopping_order_item){ - - if($shopping_order_item->product){ - if(!in_array($shopping_order_item->product->id, $this->products) && !$shopping_order_item->product->exclude_stats_sales){ //ausschließen der Produkte über filter und exclude_stats_sales - if(isset($this->objects[$shopping_order_item->product->id])){ - $qty = intval($this->objects[$shopping_order_item->product->id]['qty'] + $shopping_order_item->qty); - $total = round($this->objects[$shopping_order_item->product->id]['total'] + ($shopping_order_item->price_net * $shopping_order_item->qty), 3); - $this->objects[$shopping_order_item->product->id]['qty'] = $qty; - $this->objects[$shopping_order_item->product->id]['total'] = $total; - }else{ - $this->objects[$shopping_order_item->product->id] = [ - 'name' => $shopping_order_item->product->name, - 'number' => $shopping_order_item->product->number, - 'qty' => $shopping_order_item->qty, - 'total' => round($shopping_order_item->price_net * $shopping_order_item->qty, 3), - 'pre_qty' => 0, - 'pre_total' => 0, - 'single_commission' => $shopping_order_item->product->single_commission ? 'Ja' : 'Nein', - 'value_commission' => $shopping_order_item->product->single_commission ? $shopping_order_item->product->value_commission : '', - 'partner_commission' => $shopping_order_item->product->single_commission ? $shopping_order_item->product->partner_commission : '', - - ]; - } - }else{ - $subtotal_hide += $shopping_order_item->price_net * $shopping_order_item->qty; - } + foreach ($ShoppingOrder->shopping_order_items as $item) { + $product = $item->product; + $net = round((float) $item->price_net * (int) $item->qty, 3); + + // Ausschluss über Filter und exclude_stats_sales + if (! $product || in_array($product->id, $this->products) || $product->exclude_stats_sales) { + $hidden += $net; + + continue; } + + $shown += $net; + $isComp = (int) $item->comp > 0; + if ($isComp) { + $compQty += (int) $item->qty; + } + + if (! isset($products[$product->id])) { + $products[$product->id] = [ + 'name' => $this->productName($product), + 'number' => $product->number, + 'qty' => 0, + 'comp_qty' => 0, + 'total' => 0.0, + 'single_commission' => $product->single_commission ? 'Ja' : 'Nein', + 'value_commission' => $product->single_commission ? $product->value_commission : '', + 'partner_commission' => $product->single_commission ? $product->partner_commission : '', + ]; + } + $products[$product->id]['qty'] += (int) $item->qty; + $products[$product->id]['comp_qty'] += $isComp ? (int) $item->qty : 0; + $products[$product->id]['total'] = round($products[$product->id]['total'] + $net, 3); } } - - - - $this->objects[9990] = [ - 'name' => 'Angezeigter Umsatz netto €', - 'number' => '', - 'qty' => '', - 'total' => round($subtotal_full - $subtotal_hide, 2), - 'pre_qty' => 0, - 'pre_total' => 0, - 'single_commission' => '', - 'value_commission' => '', - 'partner_commission' => '', + $total = round($shown + $hidden, 2); + return [ + 'products' => $products, + 'comp_qty' => $compQty, + 'sums' => [ + self::ROW_SHOWN => round($shown, 2), + self::ROW_HIDDEN => round($hidden, 2), + self::ROW_TOTAL => $total, + self::ROW_DISCOUNT => round($discount, 2), + self::ROW_NET => round($total - $discount, 2), + ], ]; - - $this->objects[9991] = [ - 'name' => 'Ausgeblendeter Umsatz netto €', - 'number' => '', - 'qty' => '', - 'total' => $subtotal_hide, - 'pre_qty' => 0, - 'pre_total' => 0, - 'single_commission' => '', - 'value_commission' => '', - 'partner_commission' => '', - - ]; - - $this->objects[9992] = [ - 'name' => 'Gesamter Umsatz netto € (alle Verkäufe)', - 'number' => '', - 'qty' => '', - 'total' => $subtotal_full, - 'pre_qty' => 0, - 'pre_total' => 0, - 'single_commission' => '', - 'value_commission' => '', - 'partner_commission' => '', - - ]; - - $this->objects[9998] = [ - 'name' => 'Gesamte Rabatte netto € (alle Verkäufe)', - 'number' => '', - 'qty' => '', - 'total' => ($discount), - 'pre_qty' => 0, - 'pre_total' => 0, - 'single_commission' => '', - 'value_commission' => '', - 'partner_commission' => '', - - ]; - - $this->objects[9999] = [ - 'name' => 'Gesamt netto € (alle Verkäufe)', - 'number' => '', - 'qty' => '', - 'total' => ($subtotal), - 'pre_qty' => 0, - 'pre_total' => 0, - 'single_commission' => '', - 'value_commission' => '', - 'partner_commission' => '', - - ]; - - //format total - foreach($this->objects as $key => $obj){ - $this->objects[$key]['total'] = formatNumber($obj['total']); - } - } - private function readObjectsPreview(){ - - - $shoppingOrders = $this->getShoppingOrdersBy($this->month, $this->year-1); - - $subtotal_full = 0; // gesamtumsatz - $subtotal = 0; // gesamtumsatz ohne rabatte - $discount = 0; // gesamtrabatte - $subtotal_hide = 0; // ausgeschlossene Produkte - - foreach($shoppingOrders as $ShoppingOrder){ - $subtotal_full += $ShoppingOrder->subtotal_full; - $subtotal += $ShoppingOrder->subtotal; - $discount += $ShoppingOrder->discount; - - foreach($ShoppingOrder->shopping_order_items as $shopping_order_item){ - - if($shopping_order_item->product){ - if(!in_array($shopping_order_item->product->id, $this->products) && !$shopping_order_item->product->exclude_stats_sales){ //ausschließen der Produkte über filter und exclude_stats_sales - if(isset($this->objects[$shopping_order_item->product->id])){ //einsetzen der Zahlen, wenn vorhanden - $qty = intval($this->objects[$shopping_order_item->product->id]['pre_qty'] + $shopping_order_item->qty); - $total = round($this->objects[$shopping_order_item->product->id]['pre_total'] + ($shopping_order_item->price_net * $shopping_order_item->qty), 3); - $this->objects[$shopping_order_item->product->id]['pre_qty'] = $qty; - $this->objects[$shopping_order_item->product->id]['pre_total'] = $total; - }else{ // nicht vorhanden, anlegen - $this->objects[$shopping_order_item->product->id] = [ - 'name' => $shopping_order_item->product->name, - 'number' => $shopping_order_item->product->number, - 'qty' => 0, - 'total' => 0, - 'pre_qty' => $shopping_order_item->qty, - 'pre_total' => round($shopping_order_item->price_net * $shopping_order_item->qty, 3), - 'single_commission' => $shopping_order_item->product->single_commission ? 'Ja' : 'Nein', - 'value_commission' => $shopping_order_item->product->single_commission ? $shopping_order_item->product->value_commission : '', - 'partner_commission' => $shopping_order_item->product->single_commission ? $shopping_order_item->product->partner_commission : '', - - ]; - } - }else{ - //ausgeschlossene Produkte - $subtotal_hide += $shopping_order_item->price_net * $shopping_order_item->qty; - } - } - } - } - - - - - $this->objects[9990]['pre_total'] = round($subtotal_full - $subtotal_hide, 2); - $this->objects[9991]['pre_total'] = $subtotal_hide; - $this->objects[9992]['pre_total'] = $subtotal_full; - $this->objects[9998]['pre_total'] = ($discount); - $this->objects[9999]['pre_total'] = ($subtotal); - - //format total - foreach($this->objects as $key => $obj){ - $this->objects[$key]['pre_total'] = formatNumber($obj['pre_total']); - } - - + private function productName($product): string + { + return $product->name.($product->trashed() ? ' (gelöscht)' : ''); } - -} \ No newline at end of file +} diff --git a/database/migrations/2026_09_14_100000_backfill_subtotal_full_on_extern_orders.php b/database/migrations/2026_09_14_100000_backfill_subtotal_full_on_extern_orders.php new file mode 100644 index 0000000..b3b5b10 --- /dev/null +++ b/database/migrations/2026_09_14_100000_backfill_subtotal_full_on_extern_orders.php @@ -0,0 +1,34 @@ +where('txaction', 'extern') + ->whereNull('subtotal_full') + ->update([ + 'subtotal_full' => DB::raw('COALESCE(subtotal, 0)'), + 'discount' => 0, + ]); + + DB::table('shopping_orders') + ->where('txaction', 'extern') + ->whereNull('discount') + ->update(['discount' => 0]); + } + + public function down(): void + { + // Nachgefüllte Werte sind fachlich korrekt und bleiben erhalten. + } +}; diff --git a/resources/views/admin/stats/salesvolume.blade.php b/resources/views/admin/stats/salesvolume.blade.php index 4b42498..daa6f80 100755 --- a/resources/views/admin/stats/salesvolume.blade.php +++ b/resources/views/admin/stats/salesvolume.blade.php @@ -67,8 +67,10 @@ {{__('tables.product')}} {{__('tables.article_no')}} {{__('tables.quantity')}} + davon Kompensation {{__('tables.total')}} netto € Vorjahr {{__('tables.quantity')}} + Vorjahr davon Kompensation Vorjahr {{__('tables.total')}} netto € {{__('Einzelrabatt')}} {{__('Einzelrabatt %')}} @@ -82,7 +84,8 @@
- Bei "Gesamter Umsatz", "Gesamte Rabatte", "Gesamt netto", sind die ausgeschlossenen Produkte einberechnet, da Rabatte auf Warenkorb-Ebene verrechnet werden und nicht auf Produkt-Ebene. + Alle Umsätze werden aus den Bestellpositionen (Netto-Einzelpreis × Menge) berechnet: „Angezeigter Umsatz“ + „Ausgeblendeter Umsatz“ = „Gesamter Umsatz“. Bei "Gesamter Umsatz", "Gesamte Rabatte", "Gesamt netto" sind die ausgeschlossenen Produkte einberechnet, da Rabatte auf Warenkorb-Ebene verrechnet werden und nicht auf Produkt-Ebene.
+ Kompensationsprodukte (0 € Umsatz) sind in der Menge enthalten und werden in „davon Kompensation“ separat ausgewiesen.
@@ -113,8 +116,10 @@ { data: 'name', name: 'name', orderable: true, searchable: false }, { data: 'number', name: 'number', orderable: true, searchable: false }, { data: 'qty', name: 'qty', orderable: true, searchable: false }, + { data: 'comp_qty', name: 'comp_qty', orderable: true, searchable: false }, { data: 'total', name: 'total', orderable: true, searchable: false }, { data: 'pre_qty', name: 'pre_qty', orderable: true, searchable: false }, + { data: 'pre_comp_qty', name: 'pre_comp_qty', orderable: true, searchable: false }, { data: 'pre_total', name: 'pre_total', orderable: true, searchable: false }, { data: 'single_commission', name: 'single_commission', orderable: true, searchable: false }, { data: 'value_commission', name: 'value_commission', orderable: true, searchable: false }, diff --git a/tests/Feature/StatsSalesVolumeTest.php b/tests/Feature/StatsSalesVolumeTest.php new file mode 100644 index 0000000..74c7b3e --- /dev/null +++ b/tests/Feature/StatsSalesVolumeTest.php @@ -0,0 +1,177 @@ +create(array_merge([ + 'name' => $name, + 'title' => $name, + 'number' => strtoupper(substr(md5($name), 0, 6)), + 'active' => true, + 'is_set' => false, + ], $overrides)); +} + +/** + * @param array $items + */ +function svOrder(string $createdAt, array $items, array $overrides = []): ShoppingOrder +{ + $country = Country::query()->firstOrCreate(['code' => 'DE'], [ + 'en' => 'Germany', 'de' => 'Deutschland', 'es' => 'Alemania', + 'fr' => 'Allemagne', 'it' => 'Germania', 'ru' => 'Germanija', + ]); + $shippingCountry = ShippingCountry::query()->firstOrCreate( + ['country_id' => $country->id], + ['shipping_id' => Shipping::query()->firstOrCreate(['name' => 'Standard'])->id], + ); + $shoppingUser = ShoppingUser::query()->forceCreate([ + 'billing_country_id' => $country->id, + 'shipping_country_id' => $country->id, + 'billing_firstname' => 'Test', + 'billing_lastname' => 'Kunde', + ]); + + $user = User::query()->create([ + 'email' => uniqid('sv_', true).'@test.example', + 'password' => bcrypt('password'), + ]); + + $order = ShoppingOrder::query()->forceCreate(array_merge([ + 'shopping_user_id' => $shoppingUser->id, + 'auth_user_id' => $user->id, + 'country_id' => $shippingCountry->id, + 'paid' => 1, + 'mode' => 'live', + 'txaction' => 'paid', + 'discount' => 0, + ], $overrides)); + $order->forceFill(['created_at' => $createdAt])->save(); + + foreach ($items as $item) { + ShoppingOrderItem::query()->forceCreate([ + 'shopping_order_id' => $order->id, + 'product_id' => $item['product']->id, + 'qty' => $item['qty'], + 'price' => round($item['price_net'] * 1.19, 2), + 'price_net' => $item['price_net'], + 'tax_rate' => 19, + 'comp' => $item['comp'] ?? null, + ]); + } + + return $order->fresh(); +} + +function svNumber(string $formatted): float +{ + return (float) str_replace(['.', ','], ['', '.'], $formatted); +} + +test('summen werden aus positionen berechnet und bleiben konsistent', function () { + $creme = svProduct('Creme'); + $oel = svProduct('Öl'); + $intern = svProduct('Interner Artikel', ['exclude_stats_sales' => true]); + + // Checkout-Bestellung mit Kopf-Feldern und Warenkorb-Rabatt + svOrder('2026-08-05 10:00:00', [ + ['product' => $creme, 'qty' => 2, 'price_net' => 10.0], + ['product' => $intern, 'qty' => 1, 'price_net' => 5.0], + ], ['subtotal_full' => 25.0, 'discount' => 4.0, 'subtotal' => 21.0]); + + // WooCommerce-Import ohne Kopf-Felder (subtotal_full/discount NULL) + svOrder('2026-08-20 10:00:00', [ + ['product' => $oel, 'qty' => 3, 'price_net' => 20.0], + ], ['txaction' => 'extern', 'subtotal_full' => null, 'discount' => null, 'subtotal' => 60.0]); + + // Kompensationsprodukt: 0 €, zählt in die Menge + svOrder('2026-08-22 10:00:00', [ + ['product' => $creme, 'qty' => 1, 'price_net' => 10.0], + ['product' => $creme, 'qty' => 1, 'price_net' => 0.0, 'comp' => 1], + ], ['subtotal_full' => 10.0, 'subtotal' => 10.0]); + + // Außerhalb des Monats / nicht bezahlt / dev: dürfen nicht zählen + svOrder('2026-09-01 00:00:01', [['product' => $creme, 'qty' => 9, 'price_net' => 10.0]]); + svOrder('2026-08-10 10:00:00', [['product' => $creme, 'qty' => 9, 'price_net' => 10.0]], ['paid' => 0]); + svOrder('2026-08-10 10:00:00', [['product' => $creme, 'qty' => 9, 'price_net' => 10.0]], ['mode' => 'dev']); + + // Vorjahr + svOrder('2025-08-15 10:00:00', [['product' => $creme, 'qty' => 4, 'price_net' => 9.0]], ['subtotal_full' => 36.0, 'subtotal' => 36.0]); + + $service = new Sales; + $service->setFilterVars(8, 2026, []); + $rows = $service->getObjects(); + + expect($rows[$creme->id]['qty'])->toBe(4) + ->and($rows[$creme->id]['comp_qty'])->toBe(1) + ->and(svNumber($rows[$creme->id]['total']))->toBe(30.0) + ->and($rows[$creme->id]['pre_qty'])->toBe(4) + ->and(svNumber($rows[$creme->id]['pre_total']))->toBe(36.0) + ->and($rows[$oel->id]['qty'])->toBe(3) + ->and(svNumber($rows[$oel->id]['total']))->toBe(60.0) + ->and($rows)->not->toHaveKey($intern->id); + + $shown = svNumber($rows[Sales::ROW_SHOWN]['total']); + $hidden = svNumber($rows[Sales::ROW_HIDDEN]['total']); + $total = svNumber($rows[Sales::ROW_TOTAL]['total']); + $discount = svNumber($rows[Sales::ROW_DISCOUNT]['total']); + $net = svNumber($rows[Sales::ROW_NET]['total']); + + expect($shown)->toBe(90.0) // 20 + 60 + 10 + ->and($hidden)->toBe(5.0) + ->and($total)->toBe(95.0) + ->and($shown + $hidden)->toBe($total) + ->and($discount)->toBe(4.0) + ->and($net)->toBe(91.0) + ->and($rows[Sales::ROW_COMP]['qty'])->toBe(1) + ->and(svNumber($rows[Sales::ROW_TOTAL]['pre_total']))->toBe(36.0); +}); + +test('produktfilter verschiebt umsatz nach ausgeblendet und geloeschte produkte bleiben sichtbar', function () { + $creme = svProduct('Creme'); + $alt = svProduct('Altprodukt'); + + svOrder('2026-08-05 10:00:00', [ + ['product' => $creme, 'qty' => 2, 'price_net' => 10.0], + ['product' => $alt, 'qty' => 1, 'price_net' => 7.0], + ], ['subtotal_full' => 27.0, 'subtotal' => 27.0]); + $alt->delete(); + + $service = new Sales; + $service->setFilterVars(8, 2026, [(string) $creme->id]); + $rows = $service->getObjects(); + + expect($rows)->not->toHaveKey($creme->id) + ->and($rows[$alt->id]['name'])->toBe('Altprodukt (gelöscht)') + ->and($rows[$alt->id]['qty'])->toBe(1) + ->and(svNumber($rows[Sales::ROW_SHOWN]['total']))->toBe(7.0) + ->and(svNumber($rows[Sales::ROW_HIDDEN]['total']))->toBe(20.0) + ->and(svNumber($rows[Sales::ROW_TOTAL]['total']))->toBe(27.0); +}); + +test('ganzes jahr ueber monat 13', function () { + $creme = svProduct('Creme'); + svOrder('2026-01-01 00:00:00', [['product' => $creme, 'qty' => 1, 'price_net' => 10.0]]); + svOrder('2026-12-31 23:59:59', [['product' => $creme, 'qty' => 1, 'price_net' => 10.0]]); + svOrder('2027-01-01 00:00:00', [['product' => $creme, 'qty' => 1, 'price_net' => 10.0]]); + + $service = new Sales; + $service->setFilterVars(13, 2026, []); + $rows = $service->getObjects(); + + expect($rows[$creme->id]['qty'])->toBe(2) + ->and(svNumber($rows[Sales::ROW_TOTAL]['total']))->toBe(20.0); +});