Absatzstatistik: Summen aus Bestellpositionen, Kompensationsprodukte ausgewiesen, Extern-Bestellungen nachgefüllt
- 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) <noreply@anthropic.com>
This commit is contained in:
parent
2482d073a1
commit
ef19dc2ced
6 changed files with 433 additions and 255 deletions
|
|
@ -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, '.', ''),
|
||||
|
|
|
|||
|
|
@ -1,26 +1,20 @@
|
|||
<?php
|
||||
|
||||
|
||||
namespace App\Http\Controllers\Stats;
|
||||
|
||||
use Auth;
|
||||
use Request;
|
||||
use App\User;
|
||||
use App\Exports\ExcelExport;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\ShoppingOrderItem;
|
||||
use App\Services\HTMLHelper;
|
||||
use App\Services\Stats\Sales;
|
||||
use Carbon\Carbon;
|
||||
use Faker\Core\Number;
|
||||
use App\Exports\ExcelExport;
|
||||
use App\Services\HTMLHelper;
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Services\Stats\Sales;
|
||||
use App\Models\ShoppingOrderItem;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use App\Services\BusinessPlan\ExportBot;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Request;
|
||||
|
||||
class SalesController extends Controller
|
||||
{
|
||||
|
||||
private $serviceSales;
|
||||
|
||||
public function __construct(Sales $serviceSales)
|
||||
|
|
@ -37,68 +31,72 @@ class SalesController extends Controller
|
|||
'filter_years' => 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()
|
||||
{
|
||||
|
||||
|
|
|
|||
|
|
@ -1,18 +1,43 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Stats;
|
||||
|
||||
|
||||
use Carbon\Carbon;
|
||||
use App\Services\Util;
|
||||
use App\Models\ShoppingOrder;
|
||||
use Carbon\Carbon;
|
||||
|
||||
/**
|
||||
* Absatzmengen je Produkt (admin/stats/sales/volumes).
|
||||
*
|
||||
* Alle Umsatz-Summenzeilen werden aus den Bestellpositionen (price_net × qty)
|
||||
* abgeleitet, nicht aus den Kopf-Feldern der Bestellung. Damit gilt immer:
|
||||
* Angezeigt + Ausgeblendet = Gesamter Umsatz, und Gesamt netto = Gesamter
|
||||
* Umsatz − Rabatte — unabhängig davon, ob der Import (WooCommerce, txaction
|
||||
* "extern") die Kopf-Felder gefüllt hat.
|
||||
*
|
||||
* Kompensationsprodukte (Positionen mit comp > 0, Preis 0 €) fließen in die
|
||||
* Absatzmenge ein und werden zusätzlich separat als comp_qty ausgewiesen.
|
||||
*/
|
||||
class Sales
|
||||
{
|
||||
private $month;
|
||||
private $year;
|
||||
private $products;
|
||||
private $objects;
|
||||
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<int, array>, sums: array<int, float>, comp_qty: int}
|
||||
*/
|
||||
private function aggregate($month, $year): array
|
||||
{
|
||||
$shoppingOrders = $this->getShoppingOrdersBy($this->month, $this->year);
|
||||
$this->objects = [];
|
||||
$products = [];
|
||||
$shown = 0.0;
|
||||
$hidden = 0.0;
|
||||
$discount = 0.0;
|
||||
$compQty = 0;
|
||||
|
||||
$subtotal_full = 0; // gesamtumsatz
|
||||
$subtotal = 0; // gesamtumsatz ohne rabatte
|
||||
$discount = 0; // gesamtrabatte
|
||||
$subtotal_hide = 0; // ausgeschlossene Produkte
|
||||
foreach ($this->getShoppingOrdersBy($month, $year) as $ShoppingOrder) {
|
||||
$discount += (float) $ShoppingOrder->discount;
|
||||
|
||||
foreach($shoppingOrders as $ShoppingOrder){
|
||||
$subtotal_full += $ShoppingOrder->subtotal_full;
|
||||
$subtotal += $ShoppingOrder->subtotal;
|
||||
$discount += $ShoppingOrder->discount;
|
||||
foreach ($ShoppingOrder->shopping_order_items as $item) {
|
||||
$product = $item->product;
|
||||
$net = round((float) $item->price_net * (int) $item->qty, 3);
|
||||
|
||||
foreach($ShoppingOrder->shopping_order_items as $shopping_order_item){
|
||||
// Ausschluss über Filter und exclude_stats_sales
|
||||
if (! $product || in_array($product->id, $this->products) || $product->exclude_stats_sales) {
|
||||
$hidden += $net;
|
||||
|
||||
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;
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
$total = round($shown + $hidden, 2);
|
||||
|
||||
|
||||
|
||||
$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' => '',
|
||||
|
||||
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)' : '');
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* WooCommerce-Bestellungen (txaction "extern") wurden bis 14.09.2026 ohne
|
||||
* subtotal_full/discount angelegt (NULL). Die Absatzstatistik zählte sie
|
||||
* dadurch mit 0 € Umsatz. Nachfüllen: Rabatt gibt es bei Extern-Bestellungen
|
||||
* nicht, also subtotal_full = subtotal und discount = 0.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::table('shopping_orders')
|
||||
->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.
|
||||
}
|
||||
};
|
||||
|
|
@ -67,8 +67,10 @@
|
|||
<th>{{__('tables.product')}}</th>
|
||||
<th>{{__('tables.article_no')}}</th>
|
||||
<th>{{__('tables.quantity')}}</th>
|
||||
<th>davon Kompensation</th>
|
||||
<th>{{__('tables.total')}} netto €</th>
|
||||
<th>Vorjahr {{__('tables.quantity')}}</th>
|
||||
<th>Vorjahr davon Kompensation</th>
|
||||
<th>Vorjahr {{__('tables.total')}} netto €</th>
|
||||
<th>{{__('Einzelrabatt')}}</th>
|
||||
<th>{{__('Einzelrabatt %')}}</th>
|
||||
|
|
@ -82,7 +84,8 @@
|
|||
</div>
|
||||
<br>
|
||||
<div class="alert alert-info m-2">
|
||||
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.<br>
|
||||
Kompensationsprodukte (0 € Umsatz) sind in der Menge enthalten und werden in „davon Kompensation“ separat ausgewiesen.
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
177
tests/Feature/StatsSalesVolumeTest.php
Normal file
177
tests/Feature/StatsSalesVolumeTest.php
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Country;
|
||||
use App\Models\Product;
|
||||
use App\Models\Shipping;
|
||||
use App\Models\ShippingCountry;
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\ShoppingOrderItem;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Services\Stats\Sales;
|
||||
use App\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
function svProduct(string $name, array $overrides = []): Product
|
||||
{
|
||||
return Product::query()->create(array_merge([
|
||||
'name' => $name,
|
||||
'title' => $name,
|
||||
'number' => strtoupper(substr(md5($name), 0, 6)),
|
||||
'active' => true,
|
||||
'is_set' => false,
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{product: Product, qty: int, price_net: float, comp?: int}> $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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue