query('filter', ''); $limit = $request->query('all') ? null : self::INDEX_LIMIT; $withRelations = ['shopping_user', 'shopping_payment_last', 'shipping_country.country', 'shopping_order_items.product.setItems']; // Offene Abwicklung (Status offen/in Bearbeitung) — Basis für Liste und Kachel-Zähler. // Bewusst OHNE Limit (Review 20.07.2026): das Cap galt vorher schon für die Zähler // und Filter-Listen, wodurch bei > 500 offenen Bestellungen ausgerechnet die // ältesten versandfertigen unsichtbar wurden — und der Hinweis-Banner verglich // den Post-Filter-Count, erschien also nie. Gekappt wird jetzt nur die Anzeige. $open = ShoppingOrder::query() ->with($withRelations) ->whereIn('shipped', [0, 1]) ->orderByDesc('id') ->get(); $pick = $open->where('shipped', 0)->filter->isReadyToShip()->values(); $ready = $open->where('shipped', 1)->filter->isReadyToShip()->values(); $counts = [ 'today' => ShoppingOrder::query()->whereBetween('created_at', [today(), today()->endOfDay()])->count(), 'pick' => $pick->count(), 'ready' => $ready->count(), 'shipped_today' => ShoppingOrder::query()->whereBetween('shipped_at', [today(), today()->endOfDay()])->count(), ]; $orders = match ($filter) { 'today' => ShoppingOrder::query()->with($withRelations)->whereBetween('created_at', [today(), today()->endOfDay()])->orderByDesc('id')->get(), 'pick' => $pick, 'ready' => $ready, 'shipped_today' => ShoppingOrder::query()->with($withRelations)->whereBetween('shipped_at', [today(), today()->endOfDay()])->orderByDesc('id')->get(), default => $open, }; $listTotal = $orders->count(); if ($limit !== null) { $orders = $orders->take($limit)->values(); } return view('admin.inventory.orders.index', [ 'orders' => $orders, 'counts' => $counts, 'filter' => $filter, 'listLimit' => $limit, 'listTotal' => $listTotal, ]); } /** * Statuswechsel aus dem Board: nur vorwärts (1 = konfektioniert, 2 = versendet), * nur aus offen/in Bearbeitung. Setzt das Sperr-Flag (Kundenentscheid 16.07.2026): * die Bestell-Detailseite verändert den Status danach nicht mehr automatisch. */ public function updateStatus(Request $request, int $id) { $request->validate([ 'shipped' => ['required', 'integer', Rule::in([1, 2])], ]); $shipped = (int) $request->input('shipped'); $backUrl = route('admin.inventory.orders.index', array_filter(['filter' => $request->input('back_filter')])); // Fachliche Vorbedingungen — Prüfung 08.09.2026 F-16: werden vom Statusservice // innerhalb der Zeilensperre erneut geprüft, nicht nur hier vorab. $precondition = function (ShoppingOrder $order) use ($shipped): ?string { if (! in_array((int) $order->shipped, [0, 1], true)) { return __('Bestellung ist nicht mehr in der Abwicklung (Status: :status).', ['status' => $order->getShippedType()]); } // Prüfung 08.09.2026 F-10: „versendet“ (Bestandsabzug) nur für versandfertige // Bestellungen — Zahlung eingegangen bzw. Rechnung/API (Kundenentscheid 16.07.2026, // isReadyToShip). Konfektionieren bleibt auch vor Zahlungseingang möglich. if ($shipped === 2 && ! $order->isReadyToShip()) { return __('Bestellung #:id wartet auf Zahlung und kann noch nicht als versendet gebucht werden.', ['id' => $order->id]); } return null; }; ShoppingOrder::query()->findOrFail($id); try { app(OrderStatusService::class)->setShipped($id, $shipped, auth()->id(), lockStatus: true, precondition: $precondition); } catch (ValidationException $e) { session()->flash('alert-error', $e->errors()['shipped'][0] ?? __('Statuswechsel nicht möglich.')); return redirect()->to($backUrl); } session()->flash('alert-success', (int) $request->input('shipped') === 2 ? __('Bestellung #:id als versendet gebucht — Produktbestand wurde abgezogen.', ['id' => $id]) : __('Bestellung #:id als konfektioniert markiert.', ['id' => $id])); return redirect()->to($backUrl); } /** * Rechnung + Lieferschein erstellen (halbautomatisch, Kundenentscheid 16.07.2026): * nur für Partnercenter-Bestellungen — API-Bestellungen haben ihre Rechnung im * externen Shop (wp_invoice_path). */ public function createDocuments(Request $request, int $id) { $order = ShoppingOrder::query()->findOrFail($id); if ($order->isApiOrder()) { session()->flash('alert-error', __('API-Bestellung — die Rechnung liegt im externen Shop.')); } elseif (Invoice::isInvoice($order)) { session()->flash('alert-error', __('Rechnung und Lieferschein existieren bereits.')); } else { (new InvoiceRepository($order))->create([]); session()->flash('alert-success', __('Rechnung und Lieferschein für Bestellung #:id erstellt.', ['id' => $id])); } return redirect()->route('admin.inventory.orders.index', array_filter(['filter' => $request->input('back_filter')])); } /** * AP-33b: Sammel-Dokumente — Lieferscheine (optional inkl. Rechnungen) der * ausgewählten Bestellungen als EIN zusammengeführtes PDF zum Stapeldruck. * Fehlende Dokumente werden dabei erzeugt (ohne Mailversand); API-Bestellungen * werden übersprungen (Rechnung liegt im externen Shop). */ public function batchDocuments(Request $request) { $data = $request->validate([ 'order_ids' => ['required', 'array', 'min:1', 'max:100'], 'order_ids.*' => ['integer', 'exists:shopping_orders,id'], 'type' => ['required', Rule::in(['delivery', 'combined'])], ]); $orders = ShoppingOrder::query()->whereIn('id', $data['order_ids'])->orderBy('id')->get(); $printable = $orders->reject(fn (ShoppingOrder $order) => $order->isApiOrder())->values(); if ($printable->isEmpty()) { session()->flash('alert-error', __('Nur API-Bestellungen ausgewählt — deren Rechnungen liegen im externen Shop.')); return redirect()->route('admin.inventory.orders.index'); } foreach ($printable as $order) { if (! Invoice::isInvoice($order)) { (new InvoiceRepository($order))->create([]); } else { // Fehlende Dateien (Altdaten) mit bestehender Nummer regenerieren — // prüft Rechnung UND Lieferschein (Review 20.07.2026) (new InvoiceRepository($order))->ensureFilesOnDisk(); } } $filename = ($data['type'] === 'combined' ? 'Rechnungen-Lieferscheine-' : 'Lieferscheine-') .now()->format('d-m-Y_Hi').'.pdf'; return $this->pdfResponse($this->mergedPdf($printable, $data['type'] === 'combined', $filename), $filename); } /** * Dokument-Download für Versand-Mitarbeiter (copyreader) — die allgemeine * storage_file-Route verlangt admin ≥ 7 bzw. den Besteller selbst. */ public function document(int $id, string $type) { abort_unless(in_array($type, ['invoice', 'delivery', 'combined', 'label'], true), 404); $order = ShoppingOrder::query()->findOrFail($id); // AP-32 Brückenlösung: lokal gespeicherte Kopie des DHL-Labels aus dem // WooCommerce-Shop (privater Storage — bewusst nicht die öffentliche Shop-URL) if ($type === 'label') { abort_unless($order->wp_label_path && Storage::disk('local')->exists($order->wp_label_path), 404); return $this->pdfResponse(Storage::disk('local')->get($order->wp_label_path), 'DHL-Label-Bestellung-'.$order->id.'.pdf'); } abort_unless(Invoice::isInvoice($order), 404); // Fehlende Dateien (Altdaten) mit bestehender Nummer regenerieren — vorher // lief der combined-Zweig ungeprüft in eine Exception (Review 20.07.2026) abort_unless((new InvoiceRepository($order))->ensureFilesOnDisk(), 404); if ($type === 'combined') { $filename = str_replace('Rechnung-', 'Rechnung-Lieferschein-', Invoice::getFilename($order)); return $this->pdfResponse($this->mergedPdf([$order], true, $filename), $filename); } $path = $type === 'invoice' ? Invoice::getDownloadPath($order) : Invoice::getDownloadPathDelivery($order); $filename = $type === 'invoice' ? Invoice::getFilename($order) : Invoice::getDeliveryFilename($order); abort_unless(Storage::disk('public')->exists($path), 404); return $this->pdfResponse(Storage::disk('public')->get($path), $filename); } /** * Rechnung (optional) + Lieferschein der Bestellungen zu EINEM PDF mergen — * gemeinsame Implementierung für Einzel-Kombi und Sammel-Druck. * * @param iterable $orders */ private function mergedPdf(iterable $orders, bool $withInvoice, string $filename): string { $merger = new PDFMerger; $merger->init(); foreach ($orders as $order) { if ($withInvoice) { $merger->addPDF(Invoice::getDownloadPath($order, true)); } $merger->addPDF(Invoice::getDownloadPathDelivery($order, true)); } $merger->setFileName($filename); $merger->merge(); return $merger->output(); } private function pdfResponse(string $content, string $filename) { return Response::make($content, 200) ->header('Content-Type', 'application/pdf') ->header('Content-disposition', 'inline; filename="'.$filename.'"'); } }