diff --git a/app/Console/Commands/BackfillShippedAt.php b/app/Console/Commands/BackfillShippedAt.php new file mode 100644 index 00000000..21364a2a --- /dev/null +++ b/app/Console/Commands/BackfillShippedAt.php @@ -0,0 +1,128 @@ +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 $orderIds + * @return array + */ + 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(); + } +} diff --git a/app/Http/Controllers/Admin/PaymentSalesController.php b/app/Http/Controllers/Admin/PaymentSalesController.php index 2e66fba5..16409862 100644 --- a/app/Http/Controllers/Admin/PaymentSalesController.php +++ b/app/Http/Controllers/Admin/PaymentSalesController.php @@ -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; - } - */ - } diff --git a/app/Models/ShoppingOrder.php b/app/Models/ShoppingOrder.php index 91a8d592..87f78156 100644 --- a/app/Models/ShoppingOrder.php +++ b/app/Models/ShoppingOrder.php @@ -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 */ diff --git a/app/Services/SalesVolumeService.php b/app/Services/SalesVolumeService.php new file mode 100644 index 00000000..51253650 --- /dev/null +++ b/app/Services/SalesVolumeService.php @@ -0,0 +1,425 @@ +|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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 $productIds + * @return array> + */ + 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; + } +} diff --git a/database/migrations/2026_09_02_120000_add_shipped_at_to_shopping_orders.php b/database/migrations/2026_09_02_120000_add_shipped_at_to_shopping_orders.php new file mode 100644 index 00000000..47da8cde --- /dev/null +++ b/database/migrations/2026_09_02_120000_add_shipped_at_to_shopping_orders.php @@ -0,0 +1,39 @@ +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'); + } + }); + } +}; diff --git a/resources/views/admin/payment/salesvolume.blade.php b/resources/views/admin/payment/salesvolume.blade.php index cf8af0e8..83ad2da5 100644 --- a/resources/views/admin/payment/salesvolume.blade.php +++ b/resources/views/admin/payment/salesvolume.blade.php @@ -18,24 +18,34 @@
- {{ __('navigation.sales_volumes') }} {{ __('navigation.products') }} (nach Rechnungen) + {{ __('navigation.sales_volumes') }} {{ __('navigation.products') }} – {{ $basis_label }}
{!! Form::open(['action' => route('admin_payments_sales_volumes_download'), 'class' => '']) !!} {!! Form::hidden('key', 'value') !!} - + -     +   + + +  
+
+ +
+