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.'"');
}
}

View file

@ -2,16 +2,14 @@
namespace App\Http\Controllers;
use App\Models\Setting;
use App\Models\ShoppingOrder;
use App\Models\ShoppingUser;
use App\Repositories\InvoiceRepository;
use App\Services\CustomerPriority;
use App\Services\Invoice;
use App\Services\OrderStatusService;
use App\Services\Payment;
use App\Services\PaymentService;
use App\Services\ProductStockService;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Request;
@ -46,7 +44,8 @@ class SalesController extends Controller
{
$ShoppingOrder = ShoppingOrder::find($id);
if ($ShoppingOrder->shipped == 0) {
// AP-33: kein Auto-Wechsel mehr, wenn der Status über die WaWi gesetzt wurde
if ($ShoppingOrder->shipped == 0 && ! $ShoppingOrder->shipped_status_locked) {
$ShoppingOrder->shipped = 1;
$ShoppingOrder->save();
}
@ -209,7 +208,7 @@ class SalesController extends Controller
public function usersDetail($id)
{
$ShoppingOrder = ShoppingOrder::find($id);
if($ShoppingOrder->shipped === 0){
if($ShoppingOrder->shipped === 0 && ! $ShoppingOrder->shipped_status_locked){
$ShoppingOrder->shipped = 1;
$ShoppingOrder->save();
}
@ -291,7 +290,7 @@ class SalesController extends Controller
public function customersDetail($id)
{
$ShoppingOrder = ShoppingOrder::find($id);
if($ShoppingOrder->shipped === 0){
if($ShoppingOrder->shipped === 0 && ! $ShoppingOrder->shipped_status_locked){
$ShoppingOrder->shipped = 1;
$ShoppingOrder->save();
}
@ -429,67 +428,7 @@ class SalesController extends Controller
]);
if ($data['action'] === 'store_shipped') {
// Statuswechsel + Margin-/Provisionsfolgen atomar; Lock gegen Doppelklick/zweiten Tab
DB::transaction(function () use ($data) {
$shopping_order = ShoppingOrder::query()->lockForUpdate()->findOrFail($data['id']);
$shopping_order->shipped = (int) $data['shipped'];
$shopping_order->save();
// handel Promotion Product and credit by storno
Payment::handelUserPromotionOrder($shopping_order);
Payment::handelUserShopOrder($shopping_order);
if ($shopping_order->getAPIShippedType() === 'sent' || $shopping_order->getAPIShippedType() === 'close') {
if (! $shopping_order->shipped_at) {
$shopping_order->shipped_at = now();
$shopping_order->save();
// is shipped set pending_to
if ($shopping_order->shopping_order_margin) {
if ($shopping_order->shopping_order_margin->hasPartnerCommission()) {
$days = Setting::getContentBySlug('pending_partner_commissions_in_days');
$days = $days ? $days : 20;
$partner_commission_pending_to = $shopping_order->shipped_at;
$partner_commission_pending_to->addDays($days);
$shopping_order->shopping_order_margin->partner_commission_pending_to = $partner_commission_pending_to;
$shopping_order->shopping_order_margin->save();
} else {
$days = Setting::getContentBySlug('pending_order_margins_in_days');
$days = $days ? $days : 20;
$margin_pending_to = $shopping_order->shipped_at;
$margin_pending_to->addDays($days);
$shopping_order->shopping_order_margin->margin_pending_to = $margin_pending_to;
$shopping_order->shopping_order_margin->save();
}
}
}
} else {
$shopping_order->shipped_at = null;
$shopping_order->save();
if ($shopping_order->shopping_order_margin) {
// zurücksetzen der pending_to
$shopping_order->shopping_order_margin->partner_commission_pending_to = null;
$shopping_order->shopping_order_margin->margin_pending_to = null;
$shopping_order->shopping_order_margin->save();
}
}
if ($shopping_order->getAPIShippedType() === 'cancel') {
if ($shopping_order->shopping_order_margin) {
$shopping_order->shopping_order_margin->cancellation = true;
$shopping_order->shopping_order_margin->partner_commission_pending_to = null;
$shopping_order->shopping_order_margin->margin_pending_to = null;
$shopping_order->shopping_order_margin->save();
}
} else {
if ($shopping_order->shopping_order_margin && $shopping_order->shopping_order_margin->cancellation) {
$shopping_order->shopping_order_margin->cancellation = false;
$shopping_order->shopping_order_margin->partner_commission_pending_to = null;
$shopping_order->shopping_order_margin->margin_pending_to = null;
$shopping_order->shopping_order_margin->save();
}
}
// AP-13: Verkaufs-Abgänge (Produktbestand) mit dem neuen Versandstatus abgleichen
app(ProductStockService::class)->syncSaleStock($shopping_order, auth()->id());
});
app(OrderStatusService::class)->setShipped((int) $data['id'], (int) $data['shipped'], auth()->id());
}
/* txaction ändern

View file

@ -196,6 +196,7 @@ class ShoppingOrder extends Model
'delivery' => 'array',
'shipped_at' => 'datetime',
'user_white_label' => 'boolean',
'shipped_status_locked' => 'boolean',
];
public static $shippedTypes = [
@ -358,6 +359,32 @@ class ShoppingOrder extends Model
return '';
}
/**
* AP-33a (Kundenentscheid 16.07.2026): „versandfertig" = bezahlte Bestellung.
* Zahlart Rechnung (fnc) gilt sofort als bezahlt (Versand vor Zahlungseingang),
* Vorkasse erst bei Geldeingang, API-Bestellungen (txaction=extern) immer.
*/
public function isReadyToShip(): bool
{
if (in_array($this->txaction, ['non', 'failed'], true)) {
return false;
}
if ($this->paid || in_array($this->txaction, ['paid', 'extern'], true)) {
return true;
}
return $this->shopping_payment_last?->clearingtype === 'fnc';
}
/**
* Über die Partnercenter-API (WooCommerce) übertragene Bestellung — Rechnung
* existiert im externen Shop (wp_invoice_path), im System wird keine erstellt.
*/
public function isApiOrder(): bool
{
return $this->txaction === 'extern' || $this->wp_invoice_path !== null;
}
public function getShippedType()
{
return isset(self::$shippedTypes[$this->shipped]) ? self::$shippedTypes[$this->shipped] : '';

View file

@ -0,0 +1,87 @@
<?php
namespace App\Services;
use App\Models\Setting;
use App\Models\ShoppingOrder;
use Illuminate\Support\Facades\DB;
/**
* AP-33a: Zentraler Versand-Statuswechsel einer Bestellung — extrahiert aus
* SalesController::store(), damit Verkäufe-Modal und WaWi-Abwicklungs-Board
* dieselbe Logik nutzen (Margin-/Provisionsfolgen, Storno-Flag, AP-13-Bestandsabzug).
* Läuft transaktional mit lockForUpdate gegen Doppelklick/parallele Tabs.
*/
class OrderStatusService
{
public function setShipped(int $orderId, int $shipped, ?int $userId = null, bool $lockStatus = false): ShoppingOrder
{
return DB::transaction(function () use ($orderId, $shipped, $userId, $lockStatus) {
$shopping_order = ShoppingOrder::query()->lockForUpdate()->findOrFail($orderId);
$shopping_order->shipped = $shipped;
if ($lockStatus) {
// Kundenentscheid 16.07.2026 (AP-33): Status aus der WaWi gesetzt ⇒
// die Bestell-Detailseite darf ihn nicht mehr automatisch verändern.
$shopping_order->shipped_status_locked = true;
}
$shopping_order->save();
// handel Promotion Product and credit by storno
Payment::handelUserPromotionOrder($shopping_order);
Payment::handelUserShopOrder($shopping_order);
if ($shopping_order->getAPIShippedType() === 'sent' || $shopping_order->getAPIShippedType() === 'close') {
if (! $shopping_order->shipped_at) {
$shopping_order->shipped_at = now();
$shopping_order->save();
// is shipped set pending_to
if ($shopping_order->shopping_order_margin) {
if ($shopping_order->shopping_order_margin->hasPartnerCommission()) {
$days = Setting::getContentBySlug('pending_partner_commissions_in_days');
$days = $days ? $days : 20;
$partner_commission_pending_to = $shopping_order->shipped_at;
$partner_commission_pending_to->addDays($days);
$shopping_order->shopping_order_margin->partner_commission_pending_to = $partner_commission_pending_to;
$shopping_order->shopping_order_margin->save();
} else {
$days = Setting::getContentBySlug('pending_order_margins_in_days');
$days = $days ? $days : 20;
$margin_pending_to = $shopping_order->shipped_at;
$margin_pending_to->addDays($days);
$shopping_order->shopping_order_margin->margin_pending_to = $margin_pending_to;
$shopping_order->shopping_order_margin->save();
}
}
}
} else {
$shopping_order->shipped_at = null;
$shopping_order->save();
if ($shopping_order->shopping_order_margin) {
// zurücksetzen der pending_to
$shopping_order->shopping_order_margin->partner_commission_pending_to = null;
$shopping_order->shopping_order_margin->margin_pending_to = null;
$shopping_order->shopping_order_margin->save();
}
}
if ($shopping_order->getAPIShippedType() === 'cancel') {
if ($shopping_order->shopping_order_margin) {
$shopping_order->shopping_order_margin->cancellation = true;
$shopping_order->shopping_order_margin->partner_commission_pending_to = null;
$shopping_order->shopping_order_margin->margin_pending_to = null;
$shopping_order->shopping_order_margin->save();
}
} else {
if ($shopping_order->shopping_order_margin && $shopping_order->shopping_order_margin->cancellation) {
$shopping_order->shopping_order_margin->cancellation = false;
$shopping_order->shopping_order_margin->partner_commission_pending_to = null;
$shopping_order->shopping_order_margin->margin_pending_to = null;
$shopping_order->shopping_order_margin->save();
}
}
// AP-13: Verkaufs-Abgänge (Produktbestand) mit dem neuen Versandstatus abgleichen
app(ProductStockService::class)->syncSaleStock($shopping_order, $userId);
return $shopping_order;
});
}
}

View file

@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* AP-33a (Kundenentscheid 16.07.2026, Punkt 3): Wurde der Versandstatus über das
* WaWi-Abwicklungs-Board gesetzt, darf das Öffnen der Bestell-Detailseite ihn nicht
* mehr automatisch verändern (bisheriger Auto-Wechsel offen → in Bearbeitung).
*/
public function up(): void
{
Schema::table('shopping_orders', function (Blueprint $table) {
$table->boolean('shipped_status_locked')->default(false)->after('shipped');
});
}
public function down(): void
{
Schema::table('shopping_orders', function (Blueprint $table) {
$table->dropColumn('shipped_status_locked');
});
}
};

View file

@ -0,0 +1,237 @@
@extends('layouts.layout-2')
@section('content')
@include('admin.inventory.partials.wawi-ui')
<div class="wawi-page">
<div class="wawi-page-head">
<div>
<h1 class="wawi-page-head__title">{{ __('Bestell-Abwicklung') }}</h1>
<p class="wawi-page-head__subtitle">{{ __('Tagesübersicht: konfektionieren, Dokumente drucken, versenden — versandfertig = bezahlt (Rechnung sofort, Vorkasse nach Zahlungseingang, API immer)') }}</p>
</div>
<div class="wawi-page-head__actions">
<a href="{{ route('admin.inventory.api-orders.index') }}" class="btn btn-outline-secondary btn-sm">
<span class="fas fa-cloud-download-alt mr-1"></span>{{ __('API-Bestellungen') }}
</a>
</div>
</div>
<div class="wawi-stats">
<a class="wawi-stat is-clickable text-reset" href="{{ route('admin.inventory.orders.index', ['filter' => 'today']) }}">
<div class="wawi-stat__icon"><span class="fas fa-inbox"></span></div>
<div class="wawi-stat__body">
<div class="wawi-stat__value">{{ $counts['today'] }}</div>
<div class="wawi-stat__label">{{ __('Heute eingegangen') }}</div>
</div>
</a>
<a class="wawi-stat wawi-stat--warning is-clickable text-reset" href="{{ route('admin.inventory.orders.index', ['filter' => 'pick']) }}">
<div class="wawi-stat__icon"><span class="fas fa-box-open"></span></div>
<div class="wawi-stat__body">
<div class="wawi-stat__value">{{ $counts['pick'] }}</div>
<div class="wawi-stat__label">{{ __('Zu konfektionieren') }}</div>
</div>
</a>
<a class="wawi-stat wawi-stat--ok is-clickable text-reset" href="{{ route('admin.inventory.orders.index', ['filter' => 'ready']) }}">
<div class="wawi-stat__icon"><span class="fas fa-truck-loading"></span></div>
<div class="wawi-stat__body">
<div class="wawi-stat__value">{{ $counts['ready'] }}</div>
<div class="wawi-stat__label">{{ __('Versandfertig') }}</div>
</div>
</a>
<a class="wawi-stat is-clickable text-reset" href="{{ route('admin.inventory.orders.index', ['filter' => 'shipped_today']) }}">
<div class="wawi-stat__icon"><span class="fas fa-shipping-fast"></span></div>
<div class="wawi-stat__body">
<div class="wawi-stat__value">{{ $counts['shipped_today'] }}</div>
<div class="wawi-stat__label">{{ __('Heute versendet') }}</div>
</div>
</a>
</div>
<div class="wawi-card">
<div class="wawi-toolbar">
<div class="d-flex align-items-center" style="gap:.75rem;">
@if ($filter !== '')
<a href="{{ route('admin.inventory.orders.index') }}" class="btn btn-sm btn-outline-secondary">
<span class="fas fa-times mr-1"></span>{{ __('Filter zurücksetzen') }}
</a>
@endif
<input type="search" id="of-search" class="form-control form-control-sm" style="width:240px"
placeholder="{{ __('Suche Bestellnr. / Kunde …') }}">
</div>
</div>
<div class="table-responsive">
<table class="table wawi-table" id="of-table">
<thead>
<tr>
<th>{{ __('Bestellung') }}</th>
<th>{{ __('Datum') }}</th>
<th>{{ __('Kunde') }}</th>
<th class="text-right">{{ __('Positionen') }}</th>
<th class="text-right">{{ __('Gewicht') }}</th>
<th>{{ __('Zahlung') }}</th>
<th>{{ __('Status') }}</th>
<th>{{ __('Dokumente') }}</th>
<th class="text-right">{{ __('Aktionen') }}</th>
</tr>
</thead>
<tbody>
@forelse ($orders as $order)
@php
$customer = $order->shopping_user
? trim(($order->shopping_user->shipping_firstname ?: $order->shopping_user->billing_firstname ?? '').' '.($order->shopping_user->shipping_lastname ?: $order->shopping_user->billing_lastname ?? ''))
: '';
$clearingtype = $order->shopping_payment_last?->clearingtype;
$readyToShip = $order->isReadyToShip();
$itemCount = $order->shopping_order_items->sum('qty');
@endphp
<tr class="of-row" data-search="{{ mb_strtolower($order->id.' '.$customer) }}">
<td>
<a href="{{ route('admin_sales_detail', $order->id) }}">#{{ $order->id }}</a>
@if ($order->isApiOrder())
<span class="wawi-pill" style="background:rgba(24,28,33,.06);color:#6c757d;">API</span>
@elseif ($order->user_shop_id)
<span class="wawi-pill" style="background:rgba(24,28,33,.06);color:#6c757d;">{{ __('Shop') }}</span>
@elseif ($order->promotion_user_id)
<span class="wawi-pill" style="background:rgba(24,28,33,.06);color:#6c757d;">{{ __('Promo') }}</span>
@endif
</td>
<td>{{ $order->created_at->format('d.m.Y H:i') }}</td>
<td>
<span class="wawi-item-name">{{ $customer !== '' ? $customer : '—' }}</span>
@if ($order->shipping_country?->country)
<span class="text-muted small">{{ strtoupper($order->shipping_country->country->code) }}</span>
@endif
</td>
<td class="text-right">
<button type="button" class="btn btn-sm btn-link p-0 js-of-toggle" data-target="of-items-{{ $order->id }}">
{{ $itemCount }} <span class="fas fa-chevron-down small"></span>
</button>
</td>
<td class="text-right">{{ $order->weight ? number_format($order->weight / 1000, 2, ',', '.').' kg' : '—' }}</td>
<td>
@if ($readyToShip)
<span class="wawi-pill wawi-pill--ok">{{ __('versandfertig') }}</span>
@else
<span class="wawi-pill wawi-pill--warning">{{ __('wartet auf Zahlung') }}</span>
@endif
@if ($clearingtype === 'fnc')
<div class="small text-muted">{{ __('Rechnung') }}</div>
@elseif ($clearingtype === 'vor')
<div class="small text-muted">{{ __('Vorkasse') }}</div>
@endif
</td>
<td>
@if ((int) $order->shipped === 0)
<span class="wawi-pill wawi-pill--warning">{{ __('offen') }}</span>
@else
<span class="wawi-pill" style="background:rgba(3,155,229,.12);color:#0277bd;">{{ $order->getShippedType() }}</span>
@endif
</td>
<td>
@if ($order->isApiOrder())
@if ($order->wp_invoice_path)
<a href="{{ $order->wp_invoice_path }}" target="_blank" rel="noopener" title="{{ __('Externe Rechnung (Shop)') }}">
<span class="fas fa-external-link-alt"></span> {{ __('Extern') }}
</a>
@else
<span class="text-muted small">{{ __('extern') }}</span>
@endif
@elseif (\App\Services\Invoice::isInvoice($order))
<a href="{{ route('admin.inventory.orders.document', [$order->id, 'invoice']) }}" target="_blank" title="{{ __('Rechnung') }}" class="mr-1"><span class="far fa-file-alt"></span> {{ __('Re.') }}</a>
<a href="{{ route('admin.inventory.orders.document', [$order->id, 'delivery']) }}" target="_blank" title="{{ __('Lieferschein') }}" class="mr-1"><span class="fas fa-dolly"></span> {{ __('LS') }}</a>
<a href="{{ route('admin.inventory.orders.document', [$order->id, 'combined']) }}" target="_blank" title="{{ __('Rechnung + Lieferschein') }}"><span class="fas fa-print"></span></a>
@else
<form action="{{ route('admin.inventory.orders.documents', $order->id) }}" method="post" class="d-inline">
@csrf
<input type="hidden" name="back_filter" value="{{ $filter }}">
<button type="submit" class="btn btn-sm btn-outline-secondary" title="{{ __('Rechnung + Lieferschein erstellen (ohne E-Mail-Versand)') }}"
onclick="return confirm(@json(__('Rechnung + Lieferschein jetzt erstellen? (Es wird keine E-Mail versendet — Versand wie gewohnt über die Bestell-Details.)')));">
<span class="fas fa-file-medical mr-1"></span>{{ __('erstellen') }}
</button>
</form>
@endif
</td>
<td class="text-right" style="white-space:nowrap;">
@if ((int) $order->shipped === 0)
<form action="{{ route('admin.inventory.orders.status', $order->id) }}" method="post" class="d-inline">
@csrf
<input type="hidden" name="shipped" value="1">
<input type="hidden" name="back_filter" value="{{ $filter }}">
<button type="submit" class="btn btn-sm btn-outline-primary" title="{{ __('Als konfektioniert (in Bearbeitung) markieren') }}">
<span class="fas fa-box mr-1"></span>{{ __('Konfektioniert') }}
</button>
</form>
@endif
@if (in_array((int) $order->shipped, [0, 1], true))
<form action="{{ route('admin.inventory.orders.status', $order->id) }}" method="post" class="d-inline"
onsubmit="return confirm(@json(__('Bestellung als versendet buchen? Der Produktbestand wird abgezogen.')));">
@csrf
<input type="hidden" name="shipped" value="2">
<input type="hidden" name="back_filter" value="{{ $filter }}">
<button type="submit" class="btn btn-sm btn-primary" title="{{ __('Als versendet buchen (Bestandsabzug)') }}">
<span class="fas fa-shipping-fast mr-1"></span>{{ __('Versendet') }}
</button>
</form>
@endif
</td>
</tr>
<tr class="of-items d-none" id="of-items-{{ $order->id }}">
<td colspan="9" class="pl-4" style="background:rgba(24,28,33,.02);">
<strong class="small text-muted">{{ __('Packliste') }}</strong>
<ul class="mb-1 small">
@foreach ($order->shopping_order_items as $item)
<li>
{{ $item->qty }} × {{ $item->product?->name ?? __('(Produkt gelöscht)') }}
@if ($item->product?->is_set)
<span class="wawi-pill" style="background:rgba(24,28,33,.06);color:#6c757d;">SET</span>
<ul class="text-muted">
@foreach ($item->product->setItems as $component)
<li>{{ $item->qty * $component->pivot->quantity }} × {{ $component->name }}</li>
@endforeach
</ul>
@endif
</li>
@endforeach
</ul>
</td>
</tr>
@empty
<tr>
<td colspan="9">
<div class="wawi-empty">
<div><span class="fas fa-mug-hot"></span></div>
{{ __('Nichts zu tun — keine Bestellungen in diesem Filter.') }}
</div>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@if (($listLimit ?? null) !== null && $orders->count() >= $listLimit)
<p class="text-muted small px-3 pb-2 mb-0">
{{ __('Es werden die neuesten :n Bestellungen angezeigt.', ['n' => $listLimit]) }}
<a href="{{ route('admin.inventory.orders.index', array_filter(['all' => 1, 'filter' => $filter])) }}">{{ __('Alle anzeigen') }}</a>
</p>
@endif
</div>
</div>
<script>
$(function () {
$('.js-of-toggle').on('click', function () {
$('#' + $(this).data('target')).toggleClass('d-none');
});
$('#of-search').on('input', function () {
var term = ($(this).val() || '').toLowerCase().trim();
$('#of-table tbody tr.of-row').each(function () {
var show = term === '' || ($(this).data('search') || '').toString().indexOf(term) !== -1;
$(this).toggle(show);
if (!show) {
$('#of-items-' + $(this).find('a').first().text().replace('#', '')).addClass('d-none');
}
});
});
});
</script>
@endsection

View file

@ -256,11 +256,25 @@
<div>{{ __('Ausgang / Ausschuss') }}</div>
</a>
</li>
<li class="sidenav-item{{ Request::is('admin/inventory/api-orders*') ? ' active' : '' }}">
<a href="{{ route('admin.inventory.api-orders.index') }}" class="sidenav-link"><i
class="sidenav-icon ion ion-md-cloud-download"></i>
<div>{{ __('API-Bestellungen') }}</div>
<li class="sidenav-item @if (Request::is('admin/inventory/orders*', 'admin/inventory/api-orders*')) open @endif">
<a href="javascript:void(0)" class="sidenav-link sidenav-toggle">
<i class="sidenav-icon ion ion-md-cart"></i>
<div>{{ __('Bestellungen') }}</div>
</a>
<ul class="sidenav-menu">
<li class="sidenav-item{{ Request::is('admin/inventory/orders*') ? ' active' : '' }}">
<a href="{{ route('admin.inventory.orders.index') }}" class="sidenav-link"><i
class="sidenav-icon ion ion-md-checkbox-outline"></i>
<div>{{ __('Abwicklung') }}</div>
</a>
</li>
<li class="sidenav-item{{ Request::is('admin/inventory/api-orders*') ? ' active' : '' }}">
<a href="{{ route('admin.inventory.api-orders.index') }}" class="sidenav-link"><i
class="sidenav-icon ion ion-md-cloud-download"></i>
<div>{{ __('API-Bestellungen') }}</div>
</a>
</li>
</ul>
</li>
<li class="sidenav-item @if (Request::is('admin/inventory/productions*', 'admin/inventory/product-development*')) open @endif">
<a href="javascript:void(0)" class="sidenav-link sidenav-toggle">

View file

@ -7,6 +7,7 @@ use App\Http\Controllers\Admin\Inventory\GeneralSettingController as InventoryGe
use App\Http\Controllers\Admin\Inventory\LocationController as InventoryLocationController;
use App\Http\Controllers\Admin\Inventory\MaterialQualityController as InventoryMaterialQualityController;
use App\Http\Controllers\Admin\Inventory\NoticeController as InventoryNoticeController;
use App\Http\Controllers\Admin\Inventory\OrderFulfillmentController as InventoryOrderFulfillmentController;
use App\Http\Controllers\Admin\Inventory\PackagingItemController as InventoryPackagingItemController;
use App\Http\Controllers\Admin\Inventory\PackagingMaterialController as InventoryPackagingMaterialController;
use App\Http\Controllers\Admin\Inventory\ProductDevelopmentController as InventoryProductDevelopmentController;
@ -330,6 +331,10 @@ Route::domain(config('app.domain'))->group(function () {
Route::get('productions/{production}/copy', [InventoryProductionController::class, 'copy'])->name('productions.copy');
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/{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');
});
});

View file

@ -0,0 +1,264 @@
<?php
use App\Models\Country;
use App\Models\Product;
use App\Models\ProductStockMovement;
use App\Models\Setting;
use App\Models\Shipping;
use App\Models\ShippingCountry;
use App\Models\ShoppingOrder;
use App\Models\ShoppingOrderItem;
use App\Models\ShoppingPayment;
use App\Models\ShoppingUser;
use App\Models\UserAccount;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function ofMakeUser(int $adminLevel = 1): User
{
$user = User::query()->create([
'email' => uniqid('of_', true).'@test.example',
'password' => bcrypt('password'),
]);
$account = UserAccount::query()->create([
'm_first_name' => 'Versand',
'm_last_name' => 'Mitarbeiter',
'country_id' => ofCountry()->id,
]);
$user->forceFill([
'admin' => $adminLevel,
'confirmed' => true,
'active' => true,
'wizard' => 100,
'blocked' => false,
'account_id' => $account->id,
])->save();
return $user->fresh();
}
function ofCountry(): Country
{
return Country::query()->where('code', 'DE')->first() ?? Country::query()->forceCreate([
'code' => 'DE',
'en' => 'Germany',
'de' => 'Deutschland',
'es' => 'Alemania',
'fr' => 'Allemagne',
'it' => 'Germania',
'ru' => 'Germanija',
]);
}
/**
* @param array<int, array{product: Product, qty: int}> $items
*/
function ofMakeOrder(array $items = [], array $overrides = [], ?string $clearingtype = null): ShoppingOrder
{
$country = ofCountry();
$shipping = Shipping::query()->first() ?? Shipping::query()->forceCreate(['name' => 'Standard']);
$shippingCountry = ShippingCountry::query()->first() ?? ShippingCountry::query()->forceCreate([
'shipping_id' => $shipping->id,
'country_id' => $country->id,
]);
$shoppingUser = ShoppingUser::query()->forceCreate([
'billing_country_id' => $country->id,
'shipping_country_id' => $country->id,
'billing_firstname' => 'Otto',
'billing_lastname' => 'Abwickler',
'billing_address' => 'Teststraße 1',
'billing_zipcode' => '12345',
'billing_city' => 'Teststadt',
]);
$order = ShoppingOrder::query()->forceCreate(array_merge([
'shopping_user_id' => $shoppingUser->id,
'auth_user_id' => ofMakeUser(0)->id,
'country_id' => $shippingCountry->id,
'shipped' => 0,
'txaction' => 'paid',
'total' => 50,
'subtotal' => 42.02,
'tax' => 7.98,
'total_shipping' => 55,
'weight' => 500,
], $overrides));
foreach ($items as $item) {
ShoppingOrderItem::query()->forceCreate([
'shopping_order_id' => $order->id,
'product_id' => $item['product']->id,
'qty' => $item['qty'],
'price' => 10,
'price_net' => 8.4,
'tax_rate' => 19,
]);
}
if ($clearingtype !== null) {
ShoppingPayment::query()->forceCreate([
'shopping_order_id' => $order->id,
'clearingtype' => $clearingtype,
'reference' => substr(uniqid('of'), 0, 16),
'amount' => 5000,
'currency' => 'EUR',
]);
}
return $order->fresh();
}
function ofMakeProduct(string $name): Product
{
return Product::query()->create([
'name' => $name,
'title' => $name,
'active' => true,
'is_set' => false,
'price' => '10,00',
'tax' => '19',
]);
}
test('board rendert fuer versand-mitarbeiter mit kacheln und packliste', function () {
$product = ofMakeProduct('OF Creme');
ofMakeOrder([['product' => $product, 'qty' => 3]]);
$this->actingAs(ofMakeUser(1))
->get(route('admin.inventory.orders.index'))
->assertOk()
->assertSee('Bestell-Abwicklung')
->assertSee('Zu konfektionieren')
->assertSee('OF Creme')
->assertSee('versandfertig');
});
test('ohne copyreader-rechte kein zugriff', function () {
$this->actingAs(ofMakeUser(0))
->get(route('admin.inventory.orders.index'))
->assertRedirect();
});
test('versandfertig-regel: rechnung sofort, vorkasse erst bei zahlungseingang', function () {
$rechnung = ofMakeOrder([], ['txaction' => 'open'], 'fnc');
$vorkasseOffen = ofMakeOrder([], ['txaction' => 'open'], 'vor');
$vorkasseBezahlt = ofMakeOrder([], ['txaction' => 'paid'], 'vor');
$api = ofMakeOrder([], ['txaction' => 'extern', 'paid' => true]);
expect($rechnung->isReadyToShip())->toBeTrue()
->and($vorkasseOffen->isReadyToShip())->toBeFalse()
->and($vorkasseBezahlt->isReadyToShip())->toBeTrue()
->and($api->isReadyToShip())->toBeTrue();
$response = $this->actingAs(ofMakeUser(1))->get(route('admin.inventory.orders.index'));
$response->assertOk()->assertSee('wartet auf Zahlung');
});
test('konfektioniert setzt status in bearbeitung und sperrt den auto-wechsel', function () {
$order = ofMakeOrder();
$this->actingAs(ofMakeUser(1))
->post(route('admin.inventory.orders.status', $order->id), ['shipped' => 1])
->assertRedirect(route('admin.inventory.orders.index'));
$order->refresh();
expect((int) $order->shipped)->toBe(1)
->and($order->shipped_status_locked)->toBeTrue();
});
test('versendet bucht den bestandsabzug', function () {
$product = ofMakeProduct('OF Versand');
$order = ofMakeOrder([['product' => $product, 'qty' => 2]]);
$this->actingAs(ofMakeUser(1))
->post(route('admin.inventory.orders.status', $order->id), ['shipped' => 2])
->assertRedirect();
$order->refresh();
expect((int) $order->shipped)->toBe(2)
->and($order->shipped_at)->not->toBeNull()
->and($order->shipped_status_locked)->toBeTrue();
$movement = ProductStockMovement::query()->where('product_id', $product->id)->sole();
expect($movement->direction)->toBe('out')
->and((int) $movement->quantity)->toBe(2)
->and($movement->source)->toBe('sale');
});
test('board erlaubt nur vorwaerts-status (kein storno, kein zuruecksetzen)', function () {
$order = ofMakeOrder();
$staff = ofMakeUser(1);
$this->actingAs($staff)
->post(route('admin.inventory.orders.status', $order->id), ['shipped' => 10])
->assertSessionHasErrors('shipped');
$this->actingAs($staff)
->post(route('admin.inventory.orders.status', $order->id), ['shipped' => 0])
->assertSessionHasErrors('shipped');
expect((int) $order->fresh()->shipped)->toBe(0);
});
test('bereits versendete bestellung wird vom board nicht mehr angefasst', function () {
$order = ofMakeOrder([], ['shipped' => 3, 'shipped_at' => now()]);
$this->actingAs(ofMakeUser(1))
->post(route('admin.inventory.orders.status', $order->id), ['shipped' => 2])
->assertRedirect(route('admin.inventory.orders.index'));
expect((int) $order->fresh()->shipped)->toBe(3)
->and(session('alert-error'))->toContain('nicht mehr in der Abwicklung');
});
test('gesperrter status wird beim oeffnen der detailseite nicht veraendert', function () {
$locked = ofMakeOrder([], ['shipped' => 0, 'shipped_status_locked' => true]);
$unlocked = ofMakeOrder([], ['shipped' => 0]);
$admin = ofMakeUser(7);
$this->actingAs($admin)->get(route('admin_sales_detail', $locked->id))->assertOk();
$this->actingAs($admin)->get(route('admin_sales_detail', $unlocked->id))->assertOk();
expect((int) $locked->fresh()->shipped)->toBe(0)
->and((int) $unlocked->fresh()->shipped)->toBe(1);
});
test('dokumente erstellen legt rechnung und lieferschein an (ohne mail)', function () {
Mail::fake();
Storage::fake('public');
Setting::setContentBySlug('invoice-number', 1000, 'int');
$product = ofMakeProduct('OF Doku');
$order = ofMakeOrder([['product' => $product, 'qty' => 1]]);
// Die PDF-Templates werden relativ zum public/-Verzeichnis geladen (Web-Kontext)
$cwd = getcwd();
chdir(public_path());
try {
$this->actingAs(ofMakeUser(1))
->post(route('admin.inventory.orders.documents', $order->id))
->assertRedirect();
} finally {
chdir($cwd);
}
$order->refresh();
expect($order->invoice['filename'] ?? null)->not->toBeNull()
->and($order->delivery['filename'] ?? null)->not->toBeNull();
Mail::assertNothingSent();
});
test('api-bestellung bekommt keine rechnung im system', function () {
$order = ofMakeOrder([], ['txaction' => 'extern', 'wp_invoice_path' => 'https://shop.example/invoice/1.pdf']);
$this->actingAs(ofMakeUser(1))
->post(route('admin.inventory.orders.documents', $order->id))
->assertRedirect();
expect($order->fresh()->invoice)->toBeNull()
->and(session('alert-error'))->toContain('API-Bestellung');
});