gruene-seele/app/Http/Controllers/Admin/Inventory/OrderFulfillmentController.php
Kevin Adametz 0eed974576 Warenwirtschaft: AP-32-Brückenlösung — DHL-Label aus WooCommerce übernehmen
Bestell-Übertragung (wp/order) akzeptiert wp_label_url + wp_tracking_number:
Label-PDF wird als lokale Kopie im privaten Storage gespeichert (Shop-URL ist
öffentlich erratbar; Kopie überlebt Löschung im Shop), Sendungsnummer landet
in shopping_orders.tracking. Neuer Endpoint wp/label zum Nachreichen
(Connector-Hook pr_save_dhl_label_tracking), wp/status liefert die
Sendungsnummer an den Shop zurück. Abwicklungs-Board + Bestell-Details zeigen
Label-Download (copyreader-Dokumentroute type=label, Fallback Shop-Link) und
Sendungsnummer. Download-Fehler blockieren die Übertragung nie.

Doku: Connector-Update-Anleitung (dev/product management), Hinweise,
Testleitfaden §11, Plan-Protokoll. pint.json schließt dev/ (Fremdcode) aus.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 11:35:40 +00:00

208 lines
9.2 KiB
PHP

<?php
namespace App\Http\Controllers\Admin\Inventory;
use App\Http\Controllers\Controller;
use App\Models\ShoppingOrder;
use App\Repositories\InvoiceRepository;
use App\Services\Invoice;
use App\Services\OrderStatusService;
use App\Services\PDFMerger;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Response;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule;
/**
* AP-33a: Abwicklungs-Board („Bestellungen → Abwicklung") — kompakte Tagesübersicht
* für die Bestellabwicklung durch Versand-Mitarbeiter (copyreader): Packliste je
* Bestellung, Dokumente (Rechnung/Lieferschein), Statuswechsel konfektioniert/versendet.
* Statusaktionen sind bewusst auf Vorwärts-Schritte beschränkt; Storno/Rücksetzung
* bleibt der Verkäufe-Seite (admin ≥ 7) vorbehalten.
*/
class OrderFulfillmentController extends Controller
{
private const INDEX_LIMIT = 500;
public function index(Request $request)
{
$filter = (string) $request->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
$open = ShoppingOrder::query()
->with($withRelations)
->whereIn('shipped', [0, 1])
->orderByDesc('id')
->when($limit !== null, fn ($query) => $query->limit($limit))
->get();
$counts = [
'today' => ShoppingOrder::query()->whereDate('created_at', today())->count(),
'pick' => $open->where('shipped', 0)->filter->isReadyToShip()->count(),
'ready' => $open->where('shipped', 1)->filter->isReadyToShip()->count(),
'shipped_today' => ShoppingOrder::query()->whereDate('shipped_at', today())->count(),
];
$orders = match ($filter) {
'today' => ShoppingOrder::query()->with($withRelations)->whereDate('created_at', today())->orderByDesc('id')->get(),
'pick' => $open->where('shipped', 0)->filter->isReadyToShip()->values(),
'ready' => $open->where('shipped', 1)->filter->isReadyToShip()->values(),
'shipped_today' => ShoppingOrder::query()->with($withRelations)->whereDate('shipped_at', today())->orderByDesc('id')->get(),
default => $open,
};
return view('admin.inventory.orders.index', [
'orders' => $orders,
'counts' => $counts,
'filter' => $filter,
'listLimit' => $limit,
]);
}
/**
* 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])],
]);
$order = ShoppingOrder::query()->findOrFail($id);
if (! in_array((int) $order->shipped, [0, 1], true)) {
session()->flash('alert-error', __('Bestellung ist nicht mehr in der Abwicklung (Status: :status).', ['status' => $order->getShippedType()]));
return redirect()->route('admin.inventory.orders.index');
}
app(OrderStatusService::class)->setShipped($id, (int) $request->input('shipped'), auth()->id(), lockStatus: true);
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()->route('admin.inventory.orders.index', array_filter(['filter' => $request->input('back_filter')]));
}
/**
* 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');
}
$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.
*/
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 Response::make(Storage::disk('local')->get($order->wp_label_path), 200)
->header('Content-Type', 'application/pdf')
->header('Content-disposition', 'inline; filename="DHL-Label-Bestellung-'.$order->id.'.pdf"');
}
abort_unless(Invoice::isInvoice($order), 404);
if ($type === 'combined') {
$merger = new PDFMerger;
$merger->init();
$merger->addPDF(Invoice::getDownloadPath($order, true));
$merger->addPDF(Invoice::getDownloadPathDelivery($order, true));
$filename = str_replace('Rechnung-', 'Rechnung-Lieferschein-', Invoice::getFilename($order));
$merger->setFileName($filename);
$merger->merge();
return Response::make($merger->output(), 200)
->header('Content-Type', 'application/pdf')
->header('Content-disposition', 'inline; 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 Response::make(Storage::disk('public')->get($path), 200)
->header('Content-Type', 'application/pdf')
->header('Content-disposition', 'inline; filename="'.$filename.'"');
}
}