- 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>
237 lines
8.7 KiB
PHP
237 lines
8.7 KiB
PHP
<?php
|
||
|
||
namespace App\Services\Stats;
|
||
|
||
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
|
||
{
|
||
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()
|
||
{
|
||
$this->month = 0;
|
||
$this->year = 0;
|
||
$this->products = [];
|
||
$this->objects = [];
|
||
}
|
||
|
||
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 = is_array($products) ? $products : [];
|
||
}
|
||
|
||
public function setFilterProducts()
|
||
{
|
||
$products = [];
|
||
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
|
||
$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 {
|
||
$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');
|
||
}
|
||
|
||
// 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()
|
||
{
|
||
$this->getObjects();
|
||
$collection = collect();
|
||
|
||
foreach ($this->objects as $key => $obj) {
|
||
$collection->push([
|
||
'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()
|
||
{
|
||
$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;
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
{
|
||
$products = [];
|
||
$shown = 0.0;
|
||
$hidden = 0.0;
|
||
$discount = 0.0;
|
||
$compQty = 0;
|
||
|
||
foreach ($this->getShoppingOrdersBy($month, $year) as $ShoppingOrder) {
|
||
$discount += (float) $ShoppingOrder->discount;
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
$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),
|
||
],
|
||
];
|
||
}
|
||
|
||
private function productName($product): string
|
||
{
|
||
return $product->name.($product->trashed() ? ' (gelöscht)' : '');
|
||
}
|
||
}
|