Absatzmengen: nach Warenausgang (Versanddatum) oder nach Rechnung auswertbar

- shopping_orders.shipped_at (+ Herkunft live/dhl/geschaetzt): gesetzt im
  Model, sobald der Versandstatus auf versendet wechselt, egal an welcher
  Stelle der Status gesetzt wird. sales:backfill-shipped-at traegt
  Altbestellungen aus dem DHL-Label nach oder schaetzt.
- SalesVolumeService rechnet Absatzmengen je Produkt wahlweise nach
  Versand (inkl. externer/API-Bestellungen, Stornos im Monat des
  Stornobelegs) oder nach Rechnung. Sets werden fuer die zweite Liste in
  Einzelartikel aufgeloest.
- Admin-Seite: Umschalter Versand/Rechnung, Datenqualitaet (Anteil
  geschaetzter Versanddaten), Export Produkte und Einzelartikel.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-09-23 15:42:39 +00:00
parent a21bebc932
commit d01b9bca3a
7 changed files with 1139 additions and 432 deletions

View file

@ -0,0 +1,128 @@
<?php
namespace App\Console\Commands;
use Acme\Dhl\Models\DhlShipment;
use App\Models\ShoppingOrder;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
/**
* Traegt das Versanddatum fuer Altbestellungen nach, damit die Absatzauswertung
* "nach Versand" auch rueckwirkend funktioniert.
*
* Quellen, in dieser Reihenfolge:
* 1. Datum des ersten ausgehenden DHL-Labels – exakt, verfuegbar ab 23.10.2025.
* 2. `updated_at` der Bestellung – Schaetzung. Gegengeprueft an Bestellungen MIT
* Label liegt sie in rund 93 % innerhalb von 24 Stunden und in rund 97 % im
* richtigen Monat am tatsaechlichen Versanddatum.
*
* Die Quelle wird in `shipped_at_source` festgehalten, damit geschaetzte Monate
* in der Auswertung als solche ausgewiesen werden koennen.
*/
class BackfillShippedAt extends Command
{
protected $signature = 'sales:backfill-shipped-at
{--fix : Tatsaechlich schreiben (ohne Flag nur Vorschau)}
{--since= : Nur Bestellungen ab diesem Datum (Y-m-d)}
{--chunk=500 : Bestellungen je Verarbeitungsblock}';
protected $description = 'Traegt shipped_at fuer bereits versendete Altbestellungen nach (DHL-Label oder Schaetzung)';
public function handle(): int
{
$fix = (bool) $this->option('fix');
$since = $this->option('since');
$chunk = max(50, (int) $this->option('chunk'));
$query = ShoppingOrder::query()
->whereIn('shipped', ShoppingOrder::SHIPPED_STATES)
->whereNull('shipped_at')
->when($since, fn ($q) => $q->where('created_at', '>=', $since))
->orderBy('id');
$total = (clone $query)->count();
if ($total === 0) {
$this->info('Keine Bestellungen ohne Versanddatum gefunden.');
return self::SUCCESS;
}
$this->info("Bestellungen ohne Versanddatum: {$total}");
$counts = [ShoppingOrder::SHIPPED_AT_SOURCE_DHL => 0, ShoppingOrder::SHIPPED_AT_SOURCE_ESTIMATED => 0];
$byMonth = [];
$bar = $this->output->createProgressBar($total);
$query->chunkById($chunk, function ($orders) use ($fix, &$counts, &$byMonth, $bar) {
$labels = $this->getLabelDates($orders->pluck('id')->all());
foreach ($orders as $order) {
$shippedAt = $labels[$order->id] ?? null;
$source = ShoppingOrder::SHIPPED_AT_SOURCE_DHL;
if ($shippedAt === null) {
$shippedAt = $order->updated_at ?: $order->created_at;
$source = ShoppingOrder::SHIPPED_AT_SOURCE_ESTIMATED;
}
if ($shippedAt === null) {
continue;
}
$counts[$source]++;
$month = $shippedAt->format('Y-m');
$byMonth[$month][$source] = ($byMonth[$month][$source] ?? 0) + 1;
if ($fix) {
ShoppingOrder::withoutTimestamps(fn () => ShoppingOrder::query()
->whereKey($order->id)
->update(['shipped_at' => $shippedAt, 'shipped_at_source' => $source]));
}
$bar->advance();
}
});
$bar->finish();
$this->newLine(2);
ksort($byMonth);
$rows = [];
foreach ($byMonth as $month => $sources) {
$dhl = $sources[ShoppingOrder::SHIPPED_AT_SOURCE_DHL] ?? 0;
$estimated = $sources[ShoppingOrder::SHIPPED_AT_SOURCE_ESTIMATED] ?? 0;
$sum = $dhl + $estimated;
$rows[] = [$month, $dhl, $estimated, $sum > 0 ? round($estimated / $sum * 100, 1).' %' : '-'];
}
$this->table(['Versandmonat', 'aus DHL-Label', 'geschaetzt', 'Anteil geschaetzt'], $rows);
$this->info('Exakt aus DHL-Label: '.$counts[ShoppingOrder::SHIPPED_AT_SOURCE_DHL]);
$this->info('Geschaetzt aus updated_at: '.$counts[ShoppingOrder::SHIPPED_AT_SOURCE_ESTIMATED]);
if (! $fix) {
$this->warn('Trockenlauf! Nutze --fix um die Werte zu schreiben.');
}
return self::SUCCESS;
}
/**
* Datum des ersten ausgehenden DHL-Labels je Bestellung.
*
* @param array<int, int> $orderIds
* @return array<int, Carbon>
*/
private function getLabelDates(array $orderIds): array
{
return DhlShipment::query()
->whereIn('order_id', $orderIds)
->where('type', 'outbound')
->orderBy('created_at')
->get(['order_id', 'created_at'])
->groupBy('order_id')
->map(fn ($shipments) => $shipments->first()->created_at)
->all();
}
}

View file

@ -5,16 +5,21 @@ namespace App\Http\Controllers\Admin;
use App\Exports\UserTeamExport;
use App\Exports\xExport;
use App\Http\Controllers\Controller;
use App\Models\ShoppingOrder;
use App\Models\ShoppingOrderItem;
use App\Models\UserInvoice;
use App\Services\HTMLHelper;
use Carbon\Carbon;
use App\Services\SalesVolumeService;
use Maatwebsite\Excel\Facades\Excel;
use Request;
/**
* Absatzmengen je Produkt – wahlweise nach Versand (Warenausgang) oder nach
* Rechnungseingang (Zahlung). Die eigentliche Ermittlung liegt in
* {@see SalesVolumeService}, hier stehen nur Filter, Datatables und Exporte.
*/
class PaymentSalesController extends Controller
{
/** Zweite Liste der Seite: Sets/Kits in ihre Anlagen aufgeloest. */
private const LIST_ARTICLES = 'articles';
public function __construct()
{
$this->middleware('admin');
@ -22,46 +27,153 @@ class PaymentSalesController extends Controller
public function index()
{
$this->setFilterVars();
$service = $this->makeService();
$data = [
'filter_months' => HTMLHelper::getTransMonths(),
'filter_years' => HTMLHelper::getYearRange(2022),
'filter_basis' => SalesVolumeService::basisOptions(),
'basis' => $service->getBasis(),
'basis_label' => $service->getBasisLabel(),
'quality' => $service->getQualityStats(),
];
return view('admin.payment.salesvolume', $data);
}
public function datatable()
{
$service = $this->makeService();
$objects = Request::get('list') === self::LIST_ARTICLES
? $service->getArticleVolumes()
: $service->getProductVolumes();
$collection = collect();
foreach ($objects as $key => $obj) {
$collection->push([
'id' => $obj['product_id'] ?: $key,
'name' => $obj['name'],
'number' => $obj['number'],
'value' => $obj['value'],
]);
}
return \DataTables::of($collection)->toJson();
}
public function download()
{
/*
EXCEL EXPORT function */
/*
if(Request::get('action') === "exportfull_paid"){
return $this->exportFullList(1);
}
if(Request::get('action') === "exportfull_unpaid"){
return $this->exportFullList(0);
}
*/
if (Request::get('action') === 'exportfull_paid_invoice') {
return $this->exportFullListInvoice();
return $this->exportFullList();
}
if (Request::get('action') === 'export_articles') {
return $this->exportKompaktList(self::LIST_ARTICLES);
}
if (Request::get('action') === 'export') {
return $this->exportKompaktListInvoice();
return $this->exportKompaktList();
}
}
private function setFilterVars()
private function exportKompaktList(?string $list = null)
{
$service = $this->makeService();
$objects = $list === self::LIST_ARTICLES
? $service->getArticleVolumes()
: $service->getProductVolumes();
$columns = [];
$headers = ['#', 'Produkt', 'Artikelnummer', 'Menge'];
foreach ($objects as $key => $obj) {
$columns[] = [
'id' => $obj['product_id'] ?: $key,
'name' => $obj['name'],
'number' => $obj['number'],
'value' => $obj['value'],
];
}
$filename = 'mivita-absatzmengen-'.$this->getBasisSlug().'-'
.($list === self::LIST_ARTICLES ? 'einzelartikel' : 'kompakt').'-'
.session('payment_sales_vol_filter_month').'_'.session('payment_sales_vol_filter_year').'-export';
return Excel::download(new UserTeamExport($columns, $headers), $filename.'.xls');
}
private function exportFullList()
{
$service = $this->makeService();
$isShipping = $service->getBasis() === SalesVolumeService::BASIS_SHIPPING;
$headers = [
$isShipping ? 'Bestellung' : 'Rechnungsnummer',
$isShipping ? 'Versanddatum' : 'Rechnungsdatum',
'EMail',
'Zahlung',
'ProduktNummer',
'ProduktName',
'Anzahl',
'Summe',
'Hinweis',
];
$columns = [];
$total = [];
foreach ($service->getRows() as $row) {
$key = $row['product_id'] ?: $row['number'];
$total[$key] = ($total[$key] ?? 0) + $row['qty'];
$columns[] = [
'ref' => $row['ref'],
'date' => $row['date'],
'email' => $row['email'],
'payment' => $row['payment_type'],
'number' => $row['number'],
'name' => $row['name'],
'qty' => $row['qty'],
'sum' => $total[$key],
'note' => $row['note'],
];
}
$filename = 'mivita-absatzmengen-'.$this->getBasisSlug().'-voll-'
.session('payment_sales_vol_filter_month').'_'.session('payment_sales_vol_filter_year').'-export';
return Excel::download(new xExport($columns, $headers), $filename.'.xls');
}
private function makeService(): SalesVolumeService
{
$this->setFilterVars();
return new SalesVolumeService(
(int) session('payment_sales_vol_filter_month'),
(int) session('payment_sales_vol_filter_year'),
(string) session('payment_sales_vol_filter_basis'),
);
}
private function getBasisSlug(): string
{
return session('payment_sales_vol_filter_basis') === SalesVolumeService::BASIS_INVOICE
? 'rechnung'
: 'versand';
}
private function setFilterVars(): void
{
if (! session('payment_sales_vol_filter_month')) {
session(['payment_sales_vol_filter_month' => intval(date('m'))]);
}
if (! session('payment_sales_vol_filter_year')) {
session(['payment_sales_vol_filter_year' => intval(date('Y'))]);
}
if (! session('payment_sales_vol_filter_basis')) {
session(['payment_sales_vol_filter_basis' => SalesVolumeService::BASIS_SHIPPING]);
}
if (Request::get('payment_sales_vol_filter_month')) {
session(['payment_sales_vol_filter_month' => Request::get('payment_sales_vol_filter_month')]);
@ -69,396 +181,8 @@ class PaymentSalesController extends Controller
if (Request::get('payment_sales_vol_filter_year')) {
session(['payment_sales_vol_filter_year' => Request::get('payment_sales_vol_filter_year')]);
}
if (Request::get('payment_sales_vol_filter_basis')) {
session(['payment_sales_vol_filter_basis' => Request::get('payment_sales_vol_filter_basis')]);
}
}
private function exportKompaktListInvoice()
{
$objects = $this->initKompaktList();
$columns = [];
$filename = 'mivita-absatzmengen-kompakt'.session('payment_sales_vol_filter_month').'_'.session('payment_sales_vol_filter_year').'-export';
$headers = [
'#',
'Produkt',
'Artikelnummer',
'Menge',
];
if ($objects) {
foreach ($objects as $key => $obj) {
$columns[] = [
'id' => $key,
'name' => $obj['name'],
'number' => $obj['number'],
'value' => $obj['value'],
];
}
}
return Excel::download(new UserTeamExport($columns, $headers), $filename.'.xls');
}
private function exportFullListInvoice()
{
$this->setFilterVars();
$UserInvoices = UserInvoice::with('shopping_order')->with('shopping_order.shopping_user')->select('user_invoices.*')
->where('user_invoices.month', '=', session('payment_sales_vol_filter_month'))
->where('user_invoices.year', '=', session('payment_sales_vol_filter_year'))
->get();
$headers = ['Rechnungsnummer', 'Datum', 'EMail', 'Zahlung', 'ProduktNummer', 'ProduktName', 'Anzahl', 'Summe', 'Kompensation'];
$columns = [];
$hasSOID = [];
$total_value = [];
foreach ($UserInvoices as $UserInvoice) {
if ($UserInvoice->shopping_order) {
$ShoppingOrder = $UserInvoice->shopping_order;
$object = [];
$object['Rechnungsnummer'] = $UserInvoice->full_number;
$object['Datum'] = $UserInvoice->date;
$object['EMail'] = $ShoppingOrder->shopping_user ? $ShoppingOrder->shopping_user->billing_email : 'n/a';
$object['Zahlung'] = $ShoppingOrder->getPaymentForType();
if ($ShoppingOrder->payment_for === 5) { // homeparty
if ($ShoppingOrder->homeparty) {
foreach ($ShoppingOrder->homeparty->homeparty_order_items as $homeparty_item) {
$total_value[$homeparty_item->product_id] = isset($total_value[$homeparty_item->product_id]) ? $total_value[$homeparty_item->product_id] + $homeparty_item->qty : $homeparty_item->qty;
$object['ProduktNummer'] = $homeparty_item->product ? $homeparty_item->product->number : 'n/a';
$object['ProduktName'] = $homeparty_item->product ? $homeparty_item->product->name : 'n/a';
$object['Anzahl'] = $homeparty_item->qty;
$object['Summe'] = $total_value[$homeparty_item->product_id];
$object['Kompensation'] = '';
$columns[] = $object;
}
}
} elseif ($ShoppingOrder->payment_for === 8) { // collective_invoice
if ($ShoppingOrder->shopping_collect_order) {
foreach ($ShoppingOrder->shopping_collect_order->shop_items as $shop_item) {
$total_value[$shop_item['pid']] = isset($total_value[$shop_item['pid']]) ? $total_value[$shop_item['pid']] + $shop_item['qty'] : $shop_item['qty'];
$object['ProduktNummer'] = $shop_item['number'];
$object['ProduktName'] = $shop_item['name'];
$object['Anzahl'] = $shop_item['qty'];
$object['Summe'] = $total_value[$shop_item['pid']];
$object['Kompensation'] = '';
$columns[] = $object;
}
}
} else {
if ($ShoppingOrder->shopping_order_items) {
foreach ($ShoppingOrder->shopping_order_items as $shopping_order_item) {
$total_value[$shopping_order_item->product_id] = isset($total_value[$shopping_order_item->product_id]) ? $total_value[$shopping_order_item->product_id] + $shopping_order_item->qty : $shopping_order_item->qty;
$object['ProduktNummer'] = $shopping_order_item->product ? $shopping_order_item->product->number : 'n/a';
$object['ProduktName'] = $shopping_order_item->product ? $shopping_order_item->product->name : 'n/a';
$object['Anzahl'] = $shopping_order_item->qty;
$object['Summe'] = $total_value[$shopping_order_item->product_id];
$object['Kompensation'] = ($shopping_order_item->comp ? $shopping_order_item->comp : '');
$columns[] = $object;
}
}
}
$hasSOID[] = $ShoppingOrder->id;
}
}
$filename = 'mivita-absatzmengen-voll-'.session('payment_sales_vol_filter_month').'_'.session('payment_sales_vol_filter_year').'-export';
return Excel::download(new xExport($columns, $headers), $filename.'.xls');
}
private function initKompaktList()
{
$this->setFilterVars();
$UserInvoices = UserInvoice::with('shopping_order')->with('shopping_order.shopping_user')->select('user_invoices.*')
->where('user_invoices.month', '=', session('payment_sales_vol_filter_month'))
->where('user_invoices.year', '=', session('payment_sales_vol_filter_year'))
->get();
$objects = [];
foreach ($UserInvoices as $UserInvoice) {
if ($UserInvoice->shopping_order) {
$ShoppingOrder = $UserInvoice->shopping_order;
if ($ShoppingOrder->payment_for === 5) { // homeparty
if ($ShoppingOrder->homeparty) {
foreach ($ShoppingOrder->homeparty->homeparty_order_items as $homeparty_item) {
if (isset($objects[$homeparty_item->product_id])) {
$value = intval($objects[$homeparty_item->product_id]['value'] + $homeparty_item->qty);
$objects[$homeparty_item->product_id]['value'] = $value;
} else {
$objects[$homeparty_item->product_id] = [
'name' => $homeparty_item->product ? $homeparty_item->product->name : 'n/a '.$homeparty_item->product_id,
'number' => $homeparty_item->product ? $homeparty_item->product->number : 'n/a '.$homeparty_item->product_id,
'value' => $homeparty_item->qty,
];
}
}
}
} elseif ($ShoppingOrder->payment_for === 8) { // collective_invoice
if ($ShoppingOrder->shopping_collect_order) {
foreach ($ShoppingOrder->shopping_collect_order->shop_items as $shop_item) {
if (isset($objects[$shop_item['pid']])) {
$value = intval($objects[$shop_item['pid']]['value'] + $shop_item['qty']);
$objects[$shop_item['pid']]['value'] = $value;
} else {
$objects[$shop_item['pid']] = [
'name' => $shop_item['name'],
'number' => $shop_item['number'],
'value' => $shop_item['qty'],
];
}
}
}
} else {
if ($ShoppingOrder->shopping_order_items) {
foreach ($ShoppingOrder->shopping_order_items as $shopping_order_item) {
if (isset($objects[$shopping_order_item->product_id])) {
$value = intval($objects[$shopping_order_item->product_id]['value'] + $shopping_order_item->qty);
$objects[$shopping_order_item->product_id]['value'] = $value;
} else {
$objects[$shopping_order_item->product_id] = [
'name' => $shopping_order_item->product ? $shopping_order_item->product->name : 'n/a '.$shopping_order_item->product_id,
'number' => $shopping_order_item->product ? $shopping_order_item->product->number : 'n/a '.$shopping_order_item->product_id,
'value' => $shopping_order_item->qty,
];
}
}
}
}
$hasSOID[] = $ShoppingOrder->id;
}
}
return $objects;
}
public function datatable()
{
/*$collect = collect([
['id' => 1, 'name' => 'John', 'number'=>92012, 'value'=>123],
['id' => 2, 'name' => 'Jane', 'number'=>92012, 'value'=>123],
['id' => 3, 'name' => 'James', 'number'=>92012, 'value'=>123],
]);*/
$objects = $this->initKompaktList();
$collection = collect();
if ($objects) {
foreach ($objects as $key => $obj) {
$collection->push([
'id' => $key,
'name' => $obj['name'],
'number' => $obj['number'],
'value' => $obj['value'],
]);
}
}
return \DataTables::of($collection)->toJson();
}
/*
//Auswertung nach ShoppingOrder
//nach Datum created_at wann die Bestellung erstellt wurde
//Ist nicht das Datum der Rechnung, da hier teilweise die Sammelrechnungen oder Zahlungen erst in nächsten Monat erfolgen
public function exportFullList($paid = 1){
$date_start = Carbon::parse('01.'.session('payment_sales_vol_filter_month').'.'.session('payment_sales_vol_filter_year'))->format('Y-m-d H:i:s');
$date_end = Carbon::parse('01.'.session('payment_sales_vol_filter_month').'.'.session('payment_sales_vol_filter_year'))->endOfMonth()->format('Y-m-d H:i:s');
$ShoppingOrders = ShoppingOrder::where('paid', $paid)->where('mode', 'live')->whereBetween('created_at', [$date_start, $date_end])->get();
$txActions = ['prev' => 'keine Zahlung', 'appointed' => 'offen', 'failed' => 'abbruch', 'paid' => 'bezahlt'];
$headers = array('ID', 'Zahlung', 'Datum', 'EMail', 'ProduktID', 'ProduktNummer', 'ProduktName', 'Anzahl', 'Notiz', 'Gesamt');
$objects = [];
$columns = [];
$hasSOID = [];
foreach($ShoppingOrders as $ShoppingOrder){
foreach($ShoppingOrder->shopping_order_items as $shopping_order_item){
$value = "";
if($shopping_order_item->product){
if(isset($objects[$shopping_order_item->product->id])){
$value = intval($objects[$shopping_order_item->product->id]['value'] + $shopping_order_item->qty);
$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,
'value' => $shopping_order_item->qty
];
$value = $shopping_order_item->qty;
}
}
$object = [];
if(in_array($ShoppingOrder->id, $hasSOID)){
$object['ID'] = '';
$object['EMail'] = '';
$object['Zahlung'] = '';
$object['Datum'] = '';
}else{
$object['ID'] = $ShoppingOrder->id;
$object['EMail'] = $ShoppingOrder->shopping_user ? $ShoppingOrder->shopping_user->billing_email : 'n/a';
$object['Zahlung'] = isset($txActions[$ShoppingOrder->txaction]) ? $txActions[$ShoppingOrder->txaction] : $ShoppingOrder->txaction;
$object['Datum'] = $ShoppingOrder->created_at->format('d.m.Y');
}
$object['ProduktID'] = $shopping_order_item->product_id;
$object['ProduktNummer'] = $shopping_order_item->product ? $shopping_order_item->product->number : "n/a";
$object['ProduktName'] = $shopping_order_item->product ? $shopping_order_item->product->name : "n/a";
$object['Anzahl'] = $shopping_order_item->qty;
$object['Notiz'] = ($shopping_order_item->comp ? 'Compensation '.$shopping_order_item->comp : '') . ($shopping_order_item->shopping_collect_order_id ? 'Sammelbestellung '.$shopping_order_item->shopping_collect_order_id : '');
$object['Gesamt'] = $value;
$columns[] = $object;
$hasSOID[] = $ShoppingOrder->id;
}
}
if($paid){
$filename = "mivita-absatzmengen-full-paid-".session('payment_sales_vol_filter_month').'_'.session('payment_sales_vol_filter_year')."-export";
}else{
$filename = "mivita-absatzmengen-full-unpaid-".session('payment_sales_vol_filter_month').'_'.session('payment_sales_vol_filter_year')."-export";
}
return Excel::download(new xExport($columns, $headers), $filename.'.xls');
//CSV EXPORT function
$headers = array(
"Content-type" => "text/csv",
"Content-Disposition" => "attachment; filename=$fileName",
"Pragma" => "no-cache",
"Cache-Control" => "must-revalidate, post-check=0, pre-check=0",
"Expires" => "0"
);
$header = array('ID', 'Zahlung', 'Datum', 'EMail', 'ProduktID', 'ProduktNummer', 'ProduktName', 'Anzahl', 'Notiz', 'Gesamt');
$callback = function() use($columns, $header) {
$file = fopen('php://output', 'w');
fputcsv($file, $header);
$row = [];
foreach ($columns as $row) {
fputcsv($file, $row);
}
fclose($file);
};
return response()->stream($callback, 200, $headers);
}
*/
/*
//alte Funktion auswerung nach ShoppingOrder
private function testCheckFunction(){
//$date_start = Carbon::parse('01.'.session('payment_sales_vol_filter_month').'.'.session('payment_sales_vol_filter_year'))->format('Y-m-d');
//$date_end = Carbon::parse('01.'.session('payment_sales_vol_filter_month').'.'.session('payment_sales_vol_filter_year'))->endOfMonth()->format('Y-m-d');
$date_start = Carbon::parse('01.01.2024')->format('Y-m-d H:i:s');
$date_end = Carbon::parse('01.01.2024')->endOfMonth()->format('Y-m-d H:i:s');
dump($date_start);
dump($date_end);
$ShoppingOrders = ShoppingOrder::where('mode', 'live')->whereBetween('created_at', [$date_start, $date_end])->get();
$objects = [];
$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);
//$counter += $shopping_order_item->qty;
if(isset($objects[$shopping_order_item->product->id])){
$value = intval($objects[$shopping_order_item->product->id]['value'] + $shopping_order_item->qty);
$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,
'value' => $shopping_order_item->qty
];
}
}
}
}
}
$ShoppingOrderItems = ShoppingOrderItem::whereProductId(122)->whereBetween('created_at', [$date_start, $date_end])->get();
$counter = 0;
foreach($ShoppingOrderItems as $ShoppingOrderItem){
$counter += $ShoppingOrderItem->qty;
dump($ShoppingOrderItem->id);
}
// dump($objects);
dump($counter);
dd("OKAY");
}*/
/*
// alte Funktion auswerung nach ShoppingOrder
private function initSearch($returnColl = true)
{
$this->setFilterVars();
$date_start = Carbon::parse('01.'.session('payment_sales_vol_filter_month').'.'.session('payment_sales_vol_filter_year'))->format('Y-m-d H:i:s');
$date_end = Carbon::parse('01.'.session('payment_sales_vol_filter_month').'.'.session('payment_sales_vol_filter_year'))->endOfMonth()->format('Y-m-d H:i:s');
$ShoppingOrders = ShoppingOrder::where('paid', 1)->where('mode', 'live')->whereBetween('created_at', [$date_start, $date_end])->get();
$objects = [];
foreach($ShoppingOrders as $ShoppingOrder){
foreach($ShoppingOrder->shopping_order_items as $shopping_order_item){
if($shopping_order_item->product){
if(isset($objects[$shopping_order_item->product->id])){
$value = intval($objects[$shopping_order_item->product->id]['value'] + $shopping_order_item->qty);
$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,
'value' => $shopping_order_item->qty
];
}
}
}
}
if($returnColl){
$collection = collect();
foreach($objects as $key => $obj){
$collection->push([
'id' => $key,
'name' => $obj['name'],
'number' => $obj['number'],
'value' => $obj['value'],
]);
}
return $collection;
}
return $objects;
}
*/
}

View file

@ -142,7 +142,7 @@ class ShoppingOrder extends Model
use SoftDeletes;
protected $dates = ['deleted_at'];
protected $dates = ['deleted_at', 'shipped_at'];
protected $fillable = [
'shopping_user_id',
@ -177,6 +177,8 @@ class ShoppingOrder extends Model
'customer_order_source_comment',
'shipped',
'tracking',
'shipped_at',
'shipped_at_source',
];
protected $casts = [
@ -187,6 +189,7 @@ class ShoppingOrder extends Model
'is_abo' => 'bool',
'recovery_user_abo_id' => 'int',
'points' => 'float',
'shipped_at' => 'datetime',
];
public const CUSTOMER_ORDER_SOURCE_OPTIONS = [
@ -199,6 +202,49 @@ class ShoppingOrder extends Model
'other' => 'Sonstiges',
];
/**
* Versandstatus, die als "ist rausgegangen" gelten ({@see self::$shippedTypes}).
*/
public const SHIPPED_STATES = [2, 3, 4];
public const SHIPPED_AT_SOURCE_LIVE = 'live';
public const SHIPPED_AT_SOURCE_DHL = 'dhl';
public const SHIPPED_AT_SOURCE_ESTIMATED = 'geschaetzt';
/**
* Setzt den Versandzeitpunkt, sobald der Versandstatus auf "versendet" wechselt.
* Der Hook sitzt bewusst im Model, weil der Status an mehreren Stellen gesetzt
* wird (Admin-Liste, Abo-Fulfillment, API) und keine davon uebersehen werden darf.
*/
protected static function booted(): void
{
static::saving(function (self $order): void {
if ($order->shipped_at !== null) {
return;
}
if (! in_array((int) $order->shipped, self::SHIPPED_STATES, true)) {
return;
}
if ($order->exists && ! $order->isDirty('shipped')) {
return;
}
$order->shipped_at = now();
$order->shipped_at_source = self::SHIPPED_AT_SOURCE_LIVE;
});
}
/**
* Bestellungen, die im angegebenen Zeitraum versendet wurden.
*/
public function scopeShippedBetween(Builder $query, Carbon $from, Carbon $to): Builder
{
return $query->whereIn('shipped', self::SHIPPED_STATES)
->whereBetween('shipped_at', [$from, $to]);
}
/**
* @return array<string, string>
*/

View file

@ -0,0 +1,425 @@
<?php
namespace App\Services;
use App\Models\ProductBundle;
use App\Models\ShoppingOrder;
use App\Models\UserInvoice;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
/**
* Ermittelt die Absatzmengen eines Monats auf zwei klar getrennten Grundlagen:
*
* - {@see self::BASIS_SHIPPING}: was im Monat tatsaechlich rausgegangen ist
* (`shopping_orders.shipped_at`). Externe/API-Bestellungen zaehlen hier direkt,
* die spaetere Sammelrechnung wird uebersprungen, damit nichts doppelt laeuft.
* - {@see self::BASIS_INVOICE}: was im Monat fakturiert wurde
* (`user_invoices.month/year`). Externe Bestellungen erscheinen hier erst ueber
* die bezahlte Sammelrechnung des Partners.
*
* Stornorechnungen werden auf beiden Grundlagen abgezogen statt addiert.
*
* @phpstan-type VolumeRow array{ref: string, date: string, email: string, payment_type: string, product_id: int|null, number: string, name: string, qty: int, note: string}
*/
class SalesVolumeService
{
public const BASIS_SHIPPING = 'shipping';
public const BASIS_INVOICE = 'invoice';
/** Sammelrechnung des Partners – auf Versandbasis kein eigener Warenausgang. */
private const PAYMENT_FOR_COLLECTIVE_INVOICE = 8;
private const PAYMENT_FOR_HOMEPARTY = 5;
/** @var array<int, VolumeRow>|null */
private ?array $rows = null;
private Carbon $from;
private Carbon $to;
public function __construct(
private int $month,
private int $year,
private string $basis = self::BASIS_SHIPPING,
) {
if (! array_key_exists($this->basis, self::basisOptions())) {
$this->basis = self::BASIS_SHIPPING;
}
$this->from = Carbon::create($this->year, $this->month, 1)->startOfMonth();
$this->to = $this->from->copy()->endOfMonth();
}
/**
* @return array<string, string>
*/
public static function basisOptions(): array
{
return [
self::BASIS_SHIPPING => 'Absatz nach Versand (Warenausgang)',
self::BASIS_INVOICE => 'Absatz nach Rechnungseingang (Zahlung)',
];
}
public function getBasis(): string
{
return $this->basis;
}
public function getBasisLabel(): string
{
return self::basisOptions()[$this->basis];
}
/**
* Absatz je verkauftem Artikel – Sets bleiben eine eigene Zeile.
*
* @return array<int|string, array{product_id: int|null, name: string, number: string, value: int}>
*/
public function getProductVolumes(): array
{
$objects = [];
foreach ($this->getRows() as $row) {
$key = $row['product_id'] ?: 'x-'.$row['number'];
if (isset($objects[$key])) {
$objects[$key]['value'] += $row['qty'];
continue;
}
$objects[$key] = [
'product_id' => $row['product_id'],
'name' => $row['name'],
'number' => $row['number'],
'value' => $row['qty'],
];
}
return $objects;
}
/**
* Absatz je Einzelartikel – Sets/Kits sind in ihre hinterlegten Anlagen aufgeloest.
*
* @return array<int|string, array{product_id: int|null, name: string, number: string, value: int}>
*/
public function getArticleVolumes(): array
{
$rows = $this->getRows();
$bundles = $this->getBundleMap(array_filter(array_column($rows, 'product_id')));
$objects = [];
foreach ($rows as $row) {
$parts = $bundles[$row['product_id']] ?? null;
if (! $parts) {
$parts = [[
'product_id' => $row['product_id'],
'number' => $row['number'],
'name' => $row['name'],
'quantity' => 1,
]];
}
foreach ($parts as $part) {
$key = $part['product_id'] ?: 'x-'.$part['number'];
$qty = $row['qty'] * $part['quantity'];
if (isset($objects[$key])) {
$objects[$key]['value'] += $qty;
continue;
}
$objects[$key] = [
'product_id' => $part['product_id'],
'name' => $part['name'],
'number' => $part['number'],
'value' => $qty,
];
}
}
return $objects;
}
/**
* Einzelpositionen fuer die vollstaendige Liste (Export).
*
* @return array<int, VolumeRow>
*/
public function getRows(): array
{
if ($this->rows === null) {
$this->rows = $this->basis === self::BASIS_INVOICE
? $this->collectInvoiceRows()
: $this->collectShippingRows();
}
return $this->rows;
}
/**
* Datengrundlage des Monats: Anzahl Bestellungen und Anteil geschaetzter Versanddaten.
*
* @return array{orders: int, estimated: int, estimated_percent: float, without_date: int}
*/
public function getQualityStats(): array
{
if ($this->basis !== self::BASIS_SHIPPING) {
return ['orders' => 0, 'estimated' => 0, 'estimated_percent' => 0.0, 'without_date' => 0];
}
$orders = $this->shippedOrdersQuery()->count();
$estimated = $this->shippedOrdersQuery()
->where('shipped_at_source', ShoppingOrder::SHIPPED_AT_SOURCE_ESTIMATED)
->count();
$withoutDate = ShoppingOrder::query()
->where('mode', 'live')
->whereIn('shipped', ShoppingOrder::SHIPPED_STATES)
->whereNull('shipped_at')
->count();
return [
'orders' => $orders,
'estimated' => $estimated,
'estimated_percent' => $orders > 0 ? round($estimated / $orders * 100, 1) : 0.0,
'without_date' => $withoutDate,
];
}
/**
* @return array<int, VolumeRow>
*/
private function collectInvoiceRows(): array
{
$invoices = UserInvoice::query()
->with($this->orderRelations('shopping_order.'))
->with('shopping_order')
->where('month', $this->month)
->where('year', $this->year)
->get();
$rows = [];
foreach ($invoices as $invoice) {
$order = $invoice->shopping_order;
if (! $order) {
continue;
}
$isCancellation = $this->isCancellationInvoice($invoice);
$rows = array_merge($rows, $this->orderRows(
$order,
$isCancellation ? -1 : 1,
(string) $invoice->full_number,
$invoice->date ? Carbon::parse($invoice->date)->format('d.m.Y') : '',
$isCancellation ? 'Storno' : ''
));
}
return $rows;
}
/**
* @return array<int, VolumeRow>
*/
private function collectShippingRows(): array
{
$rows = [];
foreach ($this->shippedOrdersQuery()->with($this->orderRelations())->get() as $order) {
$rows = array_merge($rows, $this->orderRows(
$order,
1,
(string) $order->id,
$order->shipped_at ? $order->shipped_at->format('d.m.Y') : '',
$order->shipped_at_source === ShoppingOrder::SHIPPED_AT_SOURCE_ESTIMATED ? 'Versanddatum geschaetzt' : ''
));
}
return array_merge($rows, $this->collectReturnRows());
}
/**
* Stornos/Retouren im Monat – aber nur fuer Bestellungen, die vorher auch
* wirklich rausgegangen sind. Vor dem Versand stornierte Bestellungen wurden
* auf Versandbasis nie gezaehlt und duerfen deshalb auch nicht abgezogen werden.
*
* @return array<int, VolumeRow>
*/
private function collectReturnRows(): array
{
$cancellations = UserInvoice::query()
->with($this->orderRelations('shopping_order.'))
->with('shopping_order')
->where('cancellation', true)
->whereNull('cancellation_id')
->whereBetween('date', [$this->from->toDateString(), $this->to->toDateString()])
->get();
$rows = [];
foreach ($cancellations as $invoice) {
$order = $invoice->shopping_order;
if (! $order || $order->shipped_at === null) {
continue;
}
$rows = array_merge($rows, $this->orderRows(
$order,
-1,
(string) $invoice->full_number,
$invoice->date ? Carbon::parse($invoice->date)->format('d.m.Y') : '',
'Storno / Retoure'
));
}
return $rows;
}
/**
* @return Builder<ShoppingOrder>
*/
private function shippedOrdersQuery()
{
return ShoppingOrder::query()
->where('mode', 'live')
->where(function ($query) {
$query->whereNull('payment_for')
->orWhere('payment_for', '!=', self::PAYMENT_FOR_COLLECTIVE_INVOICE);
})
->shippedBetween($this->from, $this->to);
}
/**
* Positionen einer Bestellung – je nach Bestellart aus drei verschiedenen Quellen.
*
* @return array<int, VolumeRow>
*/
private function orderRows(ShoppingOrder $order, int $sign, string $ref, string $date, string $note): array
{
$base = [
'ref' => $ref,
'date' => $date,
'email' => $order->shopping_user ? (string) $order->shopping_user->billing_email : 'n/a',
'payment_type' => $order->getPaymentForType(),
'note' => $note,
];
$rows = [];
if ((int) $order->payment_for === self::PAYMENT_FOR_HOMEPARTY) {
if (! $order->homeparty) {
return [];
}
foreach ($order->homeparty->homeparty_order_items as $item) {
$rows[] = $base + [
'product_id' => $item->product_id ? (int) $item->product_id : null,
'number' => $item->product ? (string) $item->product->number : 'n/a '.$item->product_id,
'name' => $item->product ? (string) $item->product->name : 'n/a '.$item->product_id,
'qty' => $sign * (int) $item->qty,
];
}
return $rows;
}
if ((int) $order->payment_for === self::PAYMENT_FOR_COLLECTIVE_INVOICE) {
if (! $order->shopping_collect_order) {
return [];
}
foreach ($order->shopping_collect_order->shop_items as $item) {
$rows[] = $base + [
'product_id' => isset($item['pid']) ? (int) $item['pid'] : null,
'number' => (string) ($item['number'] ?? ''),
'name' => (string) ($item['name'] ?? ''),
'qty' => $sign * (int) ($item['qty'] ?? 0),
];
}
return $rows;
}
foreach ($order->shopping_order_items as $item) {
$rows[] = array_merge($base, [
'product_id' => $item->product_id ? (int) $item->product_id : null,
'number' => $item->product ? (string) $item->product->number : 'n/a '.$item->product_id,
'name' => $item->product ? (string) $item->product->name : 'n/a '.$item->product_id,
'qty' => $sign * (int) $item->qty,
'note' => trim($note.($item->comp ? ' Kompensation '.$item->comp : '')),
]);
}
return $rows;
}
/**
* Die eigentliche Stornorechnung hat cancellation=true und cancellation_id=null;
* die stornierte Originalrechnung traegt cancellation=true MIT cancellation_id.
*/
private function isCancellationInvoice(UserInvoice $invoice): bool
{
return (bool) $invoice->cancellation && $invoice->cancellation_id === null;
}
/**
* @return array<int, string>
*/
private function orderRelations(string $prefix = ''): array
{
return array_map(fn (string $relation): string => $prefix.$relation, [
'shopping_user',
'shopping_order_items.product',
'homeparty.homeparty_order_items.product',
'shopping_collect_order',
]);
}
/**
* Set/Kit-Anlagen der uebergebenen Produkte.
*
* @param array<int, int> $productIds
* @return array<int, array<int, array{product_id: int, number: string, name: string, quantity: int}>>
*/
private function getBundleMap(array $productIds): array
{
if (! $productIds) {
return [];
}
$map = [];
$bundles = ProductBundle::query()
->with('bundleProduct')
->whereIn('product_id', array_unique($productIds))
->orderBy('pos')
->get();
foreach ($bundles as $bundle) {
if (! $bundle->bundleProduct) {
continue;
}
$map[$bundle->product_id][] = [
'product_id' => (int) $bundle->bundle_product_id,
'number' => (string) $bundle->bundleProduct->number,
'name' => (string) $bundle->bundleProduct->name,
'quantity' => max(1, (int) $bundle->quantity),
];
}
return $map;
}
}

View file

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Absatzmengen nach Versand: `shopping_orders.shipped` ist nur ein Status ohne
* Zeitstempel, deshalb war eine Auswertung "was ist in Monat X rausgegangen"
* bisher nicht moeglich. `shipped_at` haelt den Versandzeitpunkt fest,
* `shipped_at_source` dokumentiert, woher er stammt (DHL-Label, Live-Erfassung
* oder Schaetzung aus dem Backfill).
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('shopping_orders', function (Blueprint $table) {
if (! Schema::hasColumn('shopping_orders', 'shipped_at')) {
$table->timestamp('shipped_at')->nullable()->index()->after('tracking');
}
if (! Schema::hasColumn('shopping_orders', 'shipped_at_source')) {
$table->string('shipped_at_source', 20)->nullable()->after('shipped_at');
}
});
}
public function down(): void
{
Schema::table('shopping_orders', function (Blueprint $table) {
if (Schema::hasColumn('shopping_orders', 'shipped_at_source')) {
$table->dropColumn('shipped_at_source');
}
if (Schema::hasColumn('shopping_orders', 'shipped_at')) {
$table->dropColumn('shipped_at');
}
});
}
};

View file

@ -18,24 +18,34 @@
<div class="card">
<h5 class="card-header">
{{ __('navigation.sales_volumes') }} {{ __('navigation.products') }} (nach Rechnungen)
{{ __('navigation.sales_volumes') }} {{ __('navigation.products') }} &ndash; {{ $basis_label }}
</h5>
<div class="card-body">
{!! Form::open(['action' => route('admin_payments_sales_volumes_download'), 'class' => '']) !!}
{!! Form::hidden('key', 'value') !!}
<button type="submit" name="action" value="export" class="btn btn-sm btn-primary mb-2"><i class="ion ion-md-download"></i> &nbsp;Export kompakte Liste als xls</button>
<button type="submit" name="action" value="export" class="btn btn-sm btn-primary mb-2"><i class="ion ion-md-download"></i> &nbsp;Export Produkte als xls</button>
&nbsp; &nbsp;
&nbsp;
<button type="submit" name="action" value="export_articles" class="btn btn-sm btn-primary mb-2"><i class="ion ion-md-download"></i> &nbsp;Export Einzelartikel als xls</button>
&nbsp;
<button type="submit" name="action" value="exportfull_paid_invoice" class="btn btn-sm btn-primary mb-2"><i class="ion ion-md-download"></i> &nbsp;Export vollständige Liste als xls</button>
<hr>
<div class="form-row align-items-center px-0 pb-2 pt-0">
<div class="col-12 col-sm-4 col-md-4 col-lg-4 mb-1">
<select class="custom-select" id="payment_sales_vol_filter_basis" name="payment_sales_vol_filter_basis">
@foreach($filter_basis as $key=>$value)
<option value="{{$key}}" @if($basis === $key) selected @endif>{{$value}}</option>
@endforeach
</select>
</div>
<div class="col-6 col-sm-4 col-md-4 col-lg-4 mb-1">
<select class="custom-select on_change_select_filter" name="payment_sales_vol_filter_month">
@foreach($filter_months as $key=>$value)
<option value="{{$key}}" @if(session('payment_sales_vol_filter_month') == $key) selected @endif>{{$value}}</option>
@endforeach
</select>
</select>
</div>
<div class="col-6 col-sm-4 col-md-4 col-lg-4 mb-1">
<select class="custom-select on_change_select_filter" name="payment_sales_vol_filter_year">
@ -47,10 +57,36 @@
</div>
{!! Form::close() !!}
<div class="card">
@if($basis === \App\Services\SalesVolumeService::BASIS_SHIPPING)
<div class="alert alert-light border small mb-3">
<i class="fa fa-truck text-muted"></i>
Gezählt wird, was im gewählten Monat versendet wurde (Warenausgang), inklusive der externen/API-Bestellungen.
Stornos und Retouren werden im Monat der Stornorechnung abgezogen.
<br>
<strong>Datengrundlage:</strong> {{ $quality['orders'] }} versendete Bestellungen im Monat,
davon {{ $quality['estimated'] }} mit geschätztem Versanddatum ({{ $quality['estimated_percent'] }} %).
@if($quality['estimated_percent'] > 0)
Geschätzt wird das Versanddatum bei Bestellungen ohne DHL-Label &ndash; also aus der Zeit vor der DHL-Anbindung (produktiv ab 02/2026)
oder bei anderem Versandweg. Grundlage ist dann die letzte Änderung der Bestellung.
@endif
@if($quality['without_date'] > 0)
<br><span class="text-danger">{{ $quality['without_date'] }} versendete Bestellungen haben noch kein Versanddatum &ndash; <code>php artisan sales:backfill-shipped-at --fix</code> ausführen.</span>
@endif
</div>
@else
<div class="alert alert-light border small mb-3">
<i class="fa fa-file-invoice text-muted"></i>
Gezählt wird, was im gewählten Monat fakturiert wurde (Rechnungsdatum = Zahlungseingang).
Externe/API-Bestellungen erscheinen hier erst über die bezahlte Sammelrechnung des Partners &ndash; teils im Folgemonat.
Stornorechnungen werden abgezogen.
</div>
@endif
<h6 class="mt-3">Absatz je verkauftem Artikel <small class="text-muted">(Sets zählen als ein Set)</small></h6>
<div class="card mb-4">
<div class="card-datatable table-responsive">
<table class="table table-striped table-bordered" id="datatable-sales-volume">
<thead>
<tr>
<th>{{__('#') }}</th>
@ -60,7 +96,27 @@
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
<h6>Absatz je Einzelartikel <small class="text-muted">(Sets/Kits in ihre hinterlegten Anlagen aufgelöst)</small></h6>
<div class="card">
<div class="card-datatable table-responsive">
<table class="table table-striped table-bordered" id="datatable-sales-volume-articles">
<thead>
<tr>
<th>{{__('#') }}</th>
<th>{{__('tables.product')}}</th>
<th>{{__('tables.article_no')}}</th>
<th>{{__('tables.quantity')}}</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
@ -73,46 +129,66 @@
$( document ).ready(function() {
function filterData(d) {
d.payment_sales_vol_filter_month = $('select[name=payment_sales_vol_filter_month]').val();
d.payment_sales_vol_filter_year = $('select[name=payment_sales_vol_filter_year]').val();
d.payment_sales_vol_filter_basis = $('select[name=payment_sales_vol_filter_basis]').val();
}
var oTable = $('#datatable-sales-volume').DataTable({
var tableOptions = {
"processing": true,
"serverSide": true,
"stateSave": true,
"searching": false,
ajax: {
url: '{!! route('admin_payments_sales_volumes_datatable') !!}',
data: function(d) {
d.payment_sales_vol_filter_month = $('select[name=payment_sales_vol_filter_month]').val();
d.payment_sales_vol_filter_year = $('select[name=payment_sales_vol_filter_year]').val();
}
},
"order": [[0, "asc" ]],
"columns": [
{ data: 'id', orderable: true, searchable: false },
{ data: 'name', name: 'name', orderable: true, searchable: false },
{ data: 'number', name: 'number', orderable: true, searchable: false },
{ data: 'value', name: 'value', orderable: true, searchable: false },
],
"bLengthChange": false,
"iDisplayLength": 100,
"language": {
"url": "/js/datatables-{{ \App::getLocale() }}.json"
}
};
var oTable = $('#datatable-sales-volume').DataTable($.extend({}, tableOptions, {
ajax: {
url: '{!! route('admin_payments_sales_volumes_datatable') !!}',
data: function(d) {
filterData(d);
d.list = 'products';
}
}
}));
var oTableArticles = $('#datatable-sales-volume-articles').DataTable($.extend({}, tableOptions, {
ajax: {
url: '{!! route('admin_payments_sales_volumes_datatable') !!}',
data: function(d) {
filterData(d);
d.list = 'articles';
}
}
}));
$('#payment_sales_vol_filter_basis').on('change', function(){
window.location = '{!! route('admin_payments_sales_volumes') !!}?payment_sales_vol_filter_basis=' + $(this).val();
});
$('select.on_change_select_filter').on('change', function(){
oTable.draw();
oTableArticles.draw();
});
$('input.on_keyup_input_filter').on('keyup', function(){
oTable.draw();
oTableArticles.draw();
});
});
</script>
@endsection

View file

@ -0,0 +1,269 @@
<?php
use App\Models\Country;
use App\Models\Product;
use App\Models\ProductBundle;
use App\Models\ShoppingOrder;
use App\Models\ShoppingOrderItem;
use App\Models\ShoppingUser;
use App\Models\UserAccount;
use App\Models\UserInvoice;
use App\Models\UserShop;
use App\Services\SalesVolumeService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function salesVolumeSeed(): array
{
$country = Country::create([
'code' => 'DE',
'phone' => '49',
'en' => 'Germany',
'de' => 'Deutschland',
'es' => 'Alemania',
'fr' => 'Allemagne',
'it' => 'Germania',
'ru' => 'Германия',
'eu_country' => true,
]);
$account = UserAccount::create([
'first_name' => 'Klaus',
'last_name' => 'Kunde',
'address' => 'Hauptstrasse 1',
'zipcode' => '10115',
'city' => 'Berlin',
'country_id' => $country->id,
]);
$user = User::forceCreate([
'account_id' => $account->id,
'email' => 'klaus@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
]);
$shoppingUser = ShoppingUser::create([
'auth_user_id' => $user->id,
'billing_firstname' => 'Klaus',
'billing_lastname' => 'Kunde',
'billing_address' => 'Hauptstrasse 1',
'billing_zipcode' => '10115',
'billing_city' => 'Berlin',
'billing_country_id' => $country->id,
'billing_email' => 'klaus@example.com',
'shipping_country_id' => $country->id,
'same_as_billing' => true,
]);
$shop = UserShop::create(['user_id' => $user->id, 'name' => 'Testshop', 'active' => 1]);
return [$country, $shoppingUser, $shop, $user];
}
function salesVolumeProduct(string $name, string $number, float $price = 16.9): Product
{
return Product::create([
'name' => $name,
'title' => $name,
'number' => $number,
'price' => $price,
'tax' => 19,
'active' => 1,
]);
}
function salesVolumeOrder(array $seed, array $attributes = []): ShoppingOrder
{
[$country, $shoppingUser, $shop, $user] = $seed;
return ShoppingOrder::create(array_merge([
'shopping_user_id' => $shoppingUser->id,
'auth_user_id' => $user->id,
'country_id' => $country->id,
'user_shop_id' => $shop->id,
'payment_for' => 3,
'mode' => 'live',
'txaction' => 'paid',
'shipped' => 0,
], $attributes));
}
it('setzt das Versanddatum beim Wechsel auf versendet', function () {
$order = salesVolumeOrder(salesVolumeSeed());
expect($order->shipped_at)->toBeNull();
$order->shipped = 2;
$order->save();
expect($order->fresh()->shipped_at)->not->toBeNull()
->and($order->fresh()->shipped_at_source)->toBe(ShoppingOrder::SHIPPED_AT_SOURCE_LIVE);
});
it('ueberschreibt ein vorhandenes Versanddatum nicht', function () {
$order = salesVolumeOrder(salesVolumeSeed(), [
'shipped' => 2,
'shipped_at' => '2026-05-04 10:00:00',
'shipped_at_source' => ShoppingOrder::SHIPPED_AT_SOURCE_DHL,
]);
$order->tracking = '123';
$order->save();
expect($order->fresh()->shipped_at->format('Y-m-d'))->toBe('2026-05-04')
->and($order->fresh()->shipped_at_source)->toBe(ShoppingOrder::SHIPPED_AT_SOURCE_DHL);
});
it('zaehlt auf Versandbasis den Monat des Warenausgangs, nicht den der Rechnung', function () {
$seed = salesVolumeSeed();
$product = salesVolumeProduct('Aloe Gel', '8020100', 16.9);
$order = salesVolumeOrder($seed, ['shipped' => 2, 'shipped_at' => '2026-07-03 09:00:00']);
ShoppingOrderItem::create(['shopping_order_id' => $order->id, 'product_id' => $product->id, 'qty' => 4]);
// Rechnung liegt im Vormonat (Zahlung Ende Juni), Versand erfolgte im Juli
UserInvoice::create([
'shopping_order_id' => $order->id,
'month' => 6,
'year' => 2026,
'date' => '2026-06-30',
'full_number' => 'RE-1',
'number' => 1,
]);
$shippingJuly = (new SalesVolumeService(7, 2026, SalesVolumeService::BASIS_SHIPPING))->getProductVolumes();
$invoiceJuly = (new SalesVolumeService(7, 2026, SalesVolumeService::BASIS_INVOICE))->getProductVolumes();
$invoiceJune = (new SalesVolumeService(6, 2026, SalesVolumeService::BASIS_INVOICE))->getProductVolumes();
expect($shippingJuly[$product->id]['value'])->toBe(4)
->and($invoiceJuly)->toBeEmpty()
->and($invoiceJune[$product->id]['value'])->toBe(4);
});
it('zaehlt externe API-Bestellungen auf Versandbasis mit', function () {
$seed = salesVolumeSeed();
$product = salesVolumeProduct('Direktsaft', '6030100', 26.9);
$order = salesVolumeOrder($seed, [
'payment_for' => 7,
'txaction' => 'extern_paid',
'shipped' => 2,
'shipped_at' => '2026-07-10 09:00:00',
]);
ShoppingOrderItem::create(['shopping_order_id' => $order->id, 'product_id' => $product->id, 'qty' => 3]);
$volumes = (new SalesVolumeService(7, 2026, SalesVolumeService::BASIS_SHIPPING))->getProductVolumes();
expect($volumes[$product->id]['value'])->toBe(3);
});
it('zieht Stornorechnungen ab statt sie zu addieren', function () {
$seed = salesVolumeSeed();
$product = salesVolumeProduct('Aloe Gel', '8020100', 16.9);
$order = salesVolumeOrder($seed, ['shipped' => 2, 'shipped_at' => '2026-07-05 09:00:00']);
ShoppingOrderItem::create(['shopping_order_id' => $order->id, 'product_id' => $product->id, 'qty' => 2]);
$invoice = UserInvoice::create([
'shopping_order_id' => $order->id,
'month' => 7,
'year' => 2026,
'date' => '2026-07-05',
'full_number' => 'RE-1',
'number' => 1,
]);
$cancellation = UserInvoice::create([
'shopping_order_id' => $order->id,
'month' => 7,
'year' => 2026,
'date' => '2026-07-20',
'full_number' => 'RE-2',
'number' => 2,
'cancellation' => true,
]);
$invoice->update(['cancellation' => true, 'cancellation_id' => $cancellation->id]);
$invoiceBasis = (new SalesVolumeService(7, 2026, SalesVolumeService::BASIS_INVOICE))->getProductVolumes();
$shippingBasis = (new SalesVolumeService(7, 2026, SalesVolumeService::BASIS_SHIPPING))->getProductVolumes();
// Rechnung +2, Storno -2; auf Versandbasis Warenausgang +2 und Retoure -2
expect($invoiceBasis[$product->id]['value'])->toBe(0)
->and($shippingBasis[$product->id]['value'])->toBe(0);
});
it('zieht Stornos ohne vorherigen Versand auf Versandbasis nicht ab', function () {
$seed = salesVolumeSeed();
$product = salesVolumeProduct('Aloe Gel', '8020100', 16.9);
$order = salesVolumeOrder($seed, ['shipped' => 10]);
ShoppingOrderItem::create(['shopping_order_id' => $order->id, 'product_id' => $product->id, 'qty' => 2]);
UserInvoice::create([
'shopping_order_id' => $order->id,
'month' => 7,
'year' => 2026,
'date' => '2026-07-20',
'full_number' => 'RE-2',
'number' => 2,
'cancellation' => true,
]);
$volumes = (new SalesVolumeService(7, 2026, SalesVolumeService::BASIS_SHIPPING))->getProductVolumes();
expect($volumes)->toBeEmpty();
});
it('loest Sets in ihre Anlagen auf', function () {
$seed = salesVolumeSeed();
$single = salesVolumeProduct('Direktsaft', '6030100', 26.9);
$set = salesVolumeProduct('6 x Direktsaft', '5010050', 129.9);
ProductBundle::create([
'product_id' => $set->id,
'bundle_product_id' => $single->id,
'quantity' => 6,
'pos' => 1,
]);
$order = salesVolumeOrder($seed, ['shipped' => 2, 'shipped_at' => '2026-07-08 09:00:00']);
ShoppingOrderItem::create(['shopping_order_id' => $order->id, 'product_id' => $set->id, 'qty' => 2]);
ShoppingOrderItem::create(['shopping_order_id' => $order->id, 'product_id' => $single->id, 'qty' => 1]);
$service = new SalesVolumeService(7, 2026, SalesVolumeService::BASIS_SHIPPING);
$products = $service->getProductVolumes();
$articles = $service->getArticleVolumes();
// Produktliste: Set bleibt eine eigene Zeile
expect($products[$set->id]['value'])->toBe(2)
->and($products[$single->id]['value'])->toBe(1);
// Einzelartikel: 2 Sets a 6 Flaschen + 1 Einzelflasche, Set-Zeile entfaellt
expect($articles[$single->id]['value'])->toBe(13)
->and($articles)->not->toHaveKey($set->id);
});
it('weist den Anteil geschaetzter Versanddaten aus', function () {
$seed = salesVolumeSeed();
$product = salesVolumeProduct('Aloe Gel', '8020100', 16.9);
foreach ([ShoppingOrder::SHIPPED_AT_SOURCE_DHL, ShoppingOrder::SHIPPED_AT_SOURCE_ESTIMATED] as $source) {
$order = salesVolumeOrder($seed, [
'shipped' => 2,
'shipped_at' => '2026-07-08 09:00:00',
'shipped_at_source' => $source,
]);
ShoppingOrderItem::create(['shopping_order_id' => $order->id, 'product_id' => $product->id, 'qty' => 1]);
}
$stats = (new SalesVolumeService(7, 2026, SalesVolumeService::BASIS_SHIPPING))->getQualityStats();
expect($stats['orders'])->toBe(2)
->and($stats['estimated'])->toBe(1)
->and($stats['estimated_percent'])->toBe(50.0);
});