From 1252c308205a4063e2cd7b0f70bf2a51369bf497 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Thu, 16 Jul 2026 12:00:10 +0000 Subject: [PATCH] =?UTF-8?q?Warenwirtschaft:=20AP-33b=20Sammel-Dokumente=20?= =?UTF-8?q?=E2=80=94=20Lieferscheine/Rechnungen=20der=20Auswahl=20als=20ei?= =?UTF-8?q?n=20PDF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checkbox-Auswahl im Abwicklungs-Board (Alle-auswählen, Zähler in den Buttons) + zwei Sammel-Aktionen: „Lieferscheine drucken" und „+ Rechnungen" — je ein zusammengeführtes PDF (PDFMerger/FPDI) im neuen Tab zum Stapeldruck. Fehlende Dokumente werden dabei ohne Mailversand erzeugt, auf der Platte fehlende Altdateien mit bestehender Nummer regeneriert. API-Bestellungen sind von der Auswahl ausgenommen (deaktivierte Checkbox, Rechnung liegt im externen Shop) und werden in gemischter Auswahl übersprungen; max. 100 je Druck. Co-Authored-By: Claude Fable 5 --- .../Inventory/OrderFulfillmentController.php | 50 +++++++++++++ .../admin/inventory/orders/index.blade.php | 39 +++++++++- routes/web.php | 1 + tests/Feature/OrderFulfillmentTest.php | 72 +++++++++++++++++++ 4 files changed, 159 insertions(+), 3 deletions(-) diff --git a/app/Http/Controllers/Admin/Inventory/OrderFulfillmentController.php b/app/Http/Controllers/Admin/Inventory/OrderFulfillmentController.php index 21b11b5..7321b0a 100644 --- a/app/Http/Controllers/Admin/Inventory/OrderFulfillmentController.php +++ b/app/Http/Controllers/Admin/Inventory/OrderFulfillmentController.php @@ -110,6 +110,56 @@ class OrderFulfillmentController extends Controller 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'); + } + + $merger = new PDFMerger; + $merger->init(); + + foreach ($printable as $order) { + if (! Invoice::isInvoice($order)) { + (new InvoiceRepository($order))->create([]); + } elseif (! Storage::disk('public')->exists(Invoice::getDownloadPathDelivery($order))) { + // Datei fehlt auf der Platte (Altdaten) — mit bestehender Nummer neu erzeugen + (new InvoiceRepository($order))->update([]); + } + + if ($data['type'] === 'combined') { + $merger->addPDF(Invoice::getDownloadPath($order, true)); + } + $merger->addPDF(Invoice::getDownloadPathDelivery($order, true)); + } + + $filename = ($data['type'] === 'combined' ? 'Rechnungen-Lieferscheine-' : 'Lieferscheine-') + .now()->format('d-m-Y_Hi').'.pdf'; + $merger->setFileName($filename); + $merger->merge(); + + return Response::make($merger->output(), 200) + ->header('Content-Type', 'application/pdf') + ->header('Content-disposition', 'inline; filename="'.$filename.'"'); + } + /** * Dokument-Download für Versand-Mitarbeiter (copyreader) — die allgemeine * storage_file-Route verlangt admin ≥ 7 bzw. den Besteller selbst. diff --git a/resources/views/admin/inventory/orders/index.blade.php b/resources/views/admin/inventory/orders/index.blade.php index 0c7aa5b..0da382a 100644 --- a/resources/views/admin/inventory/orders/index.blade.php +++ b/resources/views/admin/inventory/orders/index.blade.php @@ -87,7 +87,7 @@
-
+ @@ -120,6 +133,13 @@ $itemCount = $order->shopping_order_items->sum('qty'); @endphp + - @empty -
{{ __('Bestellung') }} {{ __('Kunde') }} {{ __('Status') }}
+ @if ($order->isApiOrder()) + + @else + + @endif + #{{ $order->id }} @if ($order->isApiOrder()) @@ -210,7 +230,7 @@
+ {{ __('Packliste') }} @if ($order->weight) · {{ __('Gewicht') }}: {{ number_format($order->weight / 1000, 2, ',', '.') }} kg @@ -234,7 +254,7 @@
+
{{ __('Nichts zu tun — keine Bestellungen in diesem Filter.') }} @@ -269,6 +289,19 @@ } }); }); + + // AP-33b: Auswahl für den Sammel-Druck + function refreshBatchButtons() { + var count = $('.js-of-check:checked').length; + $('.js-of-batch').prop('disabled', count === 0); + $('.of-batch-count').text(count); + } + $('#of-check-all').on('change', function () { + var checked = $(this).is(':checked'); + $('#of-table tbody tr.of-row:visible .js-of-check').prop('checked', checked); + refreshBatchButtons(); + }); + $(document).on('change', '.js-of-check', refreshBatchButtons); }); @endsection diff --git a/routes/web.php b/routes/web.php index 64bf94c..2ea78bf 100644 --- a/routes/web.php +++ b/routes/web.php @@ -332,6 +332,7 @@ Route::domain(config('app.domain'))->group(function () { Route::get('product-development', [InventoryProductDevelopmentController::class, 'index'])->name('product-development'); Route::get('api-orders', [InventoryApiOrderLogController::class, 'index'])->name('api-orders.index'); Route::get('orders', [InventoryOrderFulfillmentController::class, 'index'])->name('orders.index'); + Route::post('orders/documents/batch', [InventoryOrderFulfillmentController::class, 'batchDocuments'])->name('orders.documents.batch'); Route::post('orders/{id}/status', [InventoryOrderFulfillmentController::class, 'updateStatus'])->name('orders.status'); Route::post('orders/{id}/documents', [InventoryOrderFulfillmentController::class, 'createDocuments'])->name('orders.documents'); Route::get('orders/{id}/document/{type}', [InventoryOrderFulfillmentController::class, 'document'])->name('orders.document'); diff --git a/tests/Feature/OrderFulfillmentTest.php b/tests/Feature/OrderFulfillmentTest.php index 7f070c6..6999f3a 100644 --- a/tests/Feature/OrderFulfillmentTest.php +++ b/tests/Feature/OrderFulfillmentTest.php @@ -252,6 +252,78 @@ test('dokumente erstellen legt rechnung und lieferschein an (ohne mail)', functi Mail::assertNothingSent(); }); +test('sammel-druck erzeugt fehlende dokumente und liefert ein pdf', function () { + Mail::fake(); + Storage::fake('public'); + Setting::setContentBySlug('invoice-number', 2000, 'int'); + $product = ofMakeProduct('OF Batch'); + $orderA = ofMakeOrder([['product' => $product, 'qty' => 1]]); + $orderB = ofMakeOrder([['product' => $product, 'qty' => 2]]); + + $cwd = getcwd(); + chdir(public_path()); + try { + $response = $this->actingAs(ofMakeUser(1)) + ->post(route('admin.inventory.orders.documents.batch'), [ + 'order_ids' => [$orderA->id, $orderB->id], + 'type' => 'combined', + ]); + } finally { + chdir($cwd); + } + + $response->assertOk(); + expect($response->headers->get('Content-Type'))->toBe('application/pdf') + ->and($orderA->fresh()->invoice['filename'] ?? null)->not->toBeNull() + ->and($orderB->fresh()->delivery['filename'] ?? null)->not->toBeNull(); + Mail::assertNothingSent(); +}); + +test('sammel-druck nur mit api-bestellungen wird abgelehnt', function () { + $order = ofMakeOrder([], ['txaction' => 'extern', 'wp_invoice_path' => 'https://shop.example/invoice/2.pdf']); + + $this->actingAs(ofMakeUser(1)) + ->post(route('admin.inventory.orders.documents.batch'), [ + 'order_ids' => [$order->id], + 'type' => 'delivery', + ]) + ->assertRedirect(route('admin.inventory.orders.index')); + + expect(session('alert-error'))->toContain('API-Bestellungen') + ->and($order->fresh()->invoice)->toBeNull(); +}); + +test('sammel-druck ueberspringt api-bestellungen in gemischter auswahl', function () { + Mail::fake(); + Storage::fake('public'); + Setting::setContentBySlug('invoice-number', 3000, 'int'); + $product = ofMakeProduct('OF Mix'); + $normal = ofMakeOrder([['product' => $product, 'qty' => 1]]); + $api = ofMakeOrder([], ['txaction' => 'extern', 'wp_invoice_path' => 'https://shop.example/invoice/3.pdf']); + + $cwd = getcwd(); + chdir(public_path()); + try { + $response = $this->actingAs(ofMakeUser(1)) + ->post(route('admin.inventory.orders.documents.batch'), [ + 'order_ids' => [$normal->id, $api->id], + 'type' => 'delivery', + ]); + } finally { + chdir($cwd); + } + + $response->assertOk(); + expect($normal->fresh()->invoice['filename'] ?? null)->not->toBeNull() + ->and($api->fresh()->invoice)->toBeNull(); +}); + +test('sammel-druck validiert die auswahl', function () { + $this->actingAs(ofMakeUser(1)) + ->post(route('admin.inventory.orders.documents.batch'), ['order_ids' => [], 'type' => 'delivery']) + ->assertSessionHasErrors('order_ids'); +}); + test('api-bestellung bekommt keine rechnung im system', function () { $order = ofMakeOrder([], ['txaction' => 'extern', 'wp_invoice_path' => 'https://shop.example/invoice/1.pdf']);