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:
Kevin Adametz 2026-09-14 08:15:30 +00:00
parent 2482d073a1
commit ef19dc2ced
6 changed files with 433 additions and 255 deletions

View file

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

View file

@ -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,54 +31,58 @@ 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(
$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'],
'partner_commission' => $obj['partner_commission'],
);
];
}
}
return Excel::download(new ExcelExport($columns, $headers), $filename.'.xls');
}
}
@ -118,14 +116,6 @@ class SalesController extends Controller
);
}
public function datatable()
{

View file

@ -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,28 +47,31 @@ 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 ($this->getShoppingOrdersBy($this->month, $this->year) 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');
$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){
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');
@ -51,11 +79,17 @@ class Sales
$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();
@ -65,205 +99,139 @@ class Sales
'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;
}
$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' => '',
];
$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']);
$shown += $net;
$isComp = (int) $item->comp > 0;
if ($isComp) {
$compQty += (int) $item->qty;
}
}
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,
if (! isset($products[$product->id])) {
$products[$product->id] = [
'name' => $this->productName($product),
'number' => $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 : '',
'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 : '',
];
}
}else{
//ausgeschlossene Produkte
$subtotal_hide += $shopping_order_item->price_net * $shopping_order_item->qty;
}
}
$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]['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']);
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),
],
];
}
private function productName($product): string
{
return $product->name.($product->trashed() ? ' (gelöscht)' : '');
}
}

View file

@ -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.
}
};

View file

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

View 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);
});