Warenwirtschaft: AP-33a Bestell-Abwicklung — Nav „Bestellungen" + Fulfillment-Board

Neuer Nav-Punkt „Bestellungen" (Abwicklung + API-Bestellungen als Unterpunkte).
Board für Versand-Mitarbeiter (copyreader): 4 Filter-Kacheln (heute eingegangen /
zu konfektionieren / versandfertig / heute versendet), Packliste je Bestellung
(Sets mit Komponenten), Dokumente (Rechnung/Lieferschein/Kombi-Download über
eigene copyreader-Route, Erstellen ohne Mailversand; API-Bestellungen verlinken
die externe Rechnung), Aktionen Konfektioniert/Versendet (nur vorwärts, Storno
bleibt Verkäufe/admin). Versandfertig-Regel per Kundenentscheid: Rechnung sofort,
Vorkasse nach Zahlungseingang, API immer (ShoppingOrder::isReadyToShip()).
Statuswechsel-Logik in OrderStatusService extrahiert (Verkäufe-Modal + Board
teilen Transaktion/Lock/Margin-Folgen/AP-13-Abzug). Kundenentscheid Punkt 3:
Board-Statuswechsel setzt shipped_status_locked — die Bestell-Detailseiten
ändern den Status dann nicht mehr automatisch (offen → in Bearbeitung).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-16 11:30:57 +00:00
parent f1eb5fbc7f
commit a367d851f6
9 changed files with 818 additions and 71 deletions

View file

@ -0,0 +1,147 @@
<?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')]));
}
/**
* 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'], true), 404);
$order = ShoppingOrder::query()->findOrFail($id);
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.'"');
}
}