gruene-seele/app/Services/Stats/Sales.php
Kevin Adametz ef19dc2ced 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>
2026-09-14 08:15:30 +00:00

237 lines
8.7 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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)' : '');
}
}