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