From a367d851f66268cc2f8d8ed25bdb34932ca0df04 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Thu, 16 Jul 2026 11:30:57 +0000 Subject: [PATCH] =?UTF-8?q?Warenwirtschaft:=20AP-33a=20Bestell-Abwicklung?= =?UTF-8?q?=20=E2=80=94=20Nav=20=E2=80=9EBestellungen"=20+=20Fulfillment-B?= =?UTF-8?q?oard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Inventory/OrderFulfillmentController.php | 147 ++++++++++ app/Http/Controllers/SalesController.php | 73 +---- app/Models/ShoppingOrder.php | 27 ++ app/Services/OrderStatusService.php | 87 ++++++ ...status_locked_to_shopping_orders_table.php | 27 ++ .../admin/inventory/orders/index.blade.php | 237 ++++++++++++++++ .../layouts/includes/layout-sidenav.blade.php | 22 +- routes/web.php | 5 + tests/Feature/OrderFulfillmentTest.php | 264 ++++++++++++++++++ 9 files changed, 818 insertions(+), 71 deletions(-) create mode 100644 app/Http/Controllers/Admin/Inventory/OrderFulfillmentController.php create mode 100644 app/Services/OrderStatusService.php create mode 100644 database/migrations/2026_07_16_130000_add_shipped_status_locked_to_shopping_orders_table.php create mode 100644 resources/views/admin/inventory/orders/index.blade.php create mode 100644 tests/Feature/OrderFulfillmentTest.php diff --git a/app/Http/Controllers/Admin/Inventory/OrderFulfillmentController.php b/app/Http/Controllers/Admin/Inventory/OrderFulfillmentController.php new file mode 100644 index 0000000..21b11b5 --- /dev/null +++ b/app/Http/Controllers/Admin/Inventory/OrderFulfillmentController.php @@ -0,0 +1,147 @@ +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.'"'); + } +} diff --git a/app/Http/Controllers/SalesController.php b/app/Http/Controllers/SalesController.php index cd3aa73..87a30b6 100755 --- a/app/Http/Controllers/SalesController.php +++ b/app/Http/Controllers/SalesController.php @@ -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 diff --git a/app/Models/ShoppingOrder.php b/app/Models/ShoppingOrder.php index 356ce7d..03737d3 100644 --- a/app/Models/ShoppingOrder.php +++ b/app/Models/ShoppingOrder.php @@ -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] : ''; diff --git a/app/Services/OrderStatusService.php b/app/Services/OrderStatusService.php new file mode 100644 index 0000000..83a1b63 --- /dev/null +++ b/app/Services/OrderStatusService.php @@ -0,0 +1,87 @@ +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; + }); + } +} diff --git a/database/migrations/2026_07_16_130000_add_shipped_status_locked_to_shopping_orders_table.php b/database/migrations/2026_07_16_130000_add_shipped_status_locked_to_shopping_orders_table.php new file mode 100644 index 0000000..878daf3 --- /dev/null +++ b/database/migrations/2026_07_16_130000_add_shipped_status_locked_to_shopping_orders_table.php @@ -0,0 +1,27 @@ +boolean('shipped_status_locked')->default(false)->after('shipped'); + }); + } + + public function down(): void + { + Schema::table('shopping_orders', function (Blueprint $table) { + $table->dropColumn('shipped_status_locked'); + }); + } +}; diff --git a/resources/views/admin/inventory/orders/index.blade.php b/resources/views/admin/inventory/orders/index.blade.php new file mode 100644 index 0000000..a7a0c2f --- /dev/null +++ b/resources/views/admin/inventory/orders/index.blade.php @@ -0,0 +1,237 @@ +@extends('layouts.layout-2') + +@section('content') + @include('admin.inventory.partials.wawi-ui') + +
+
+
+

{{ __('Bestell-Abwicklung') }}

+

{{ __('Tagesübersicht: konfektionieren, Dokumente drucken, versenden — versandfertig = bezahlt (Rechnung sofort, Vorkasse nach Zahlungseingang, API immer)') }}

+
+ +
+ + + +
+
+
+ @if ($filter !== '') + + {{ __('Filter zurücksetzen') }} + + @endif + +
+
+
+ + + + + + + + + + + + + + + + @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 + + + + + + + + + + + + + + + @empty + + + + @endforelse + +
{{ __('Bestellung') }}{{ __('Datum') }}{{ __('Kunde') }}{{ __('Positionen') }}{{ __('Gewicht') }}{{ __('Zahlung') }}{{ __('Status') }}{{ __('Dokumente') }}{{ __('Aktionen') }}
+ #{{ $order->id }} + @if ($order->isApiOrder()) + API + @elseif ($order->user_shop_id) + {{ __('Shop') }} + @elseif ($order->promotion_user_id) + {{ __('Promo') }} + @endif + {{ $order->created_at->format('d.m.Y H:i') }} + {{ $customer !== '' ? $customer : '—' }} + @if ($order->shipping_country?->country) + {{ strtoupper($order->shipping_country->country->code) }} + @endif + + + {{ $order->weight ? number_format($order->weight / 1000, 2, ',', '.').' kg' : '—' }} + @if ($readyToShip) + {{ __('versandfertig') }} + @else + {{ __('wartet auf Zahlung') }} + @endif + @if ($clearingtype === 'fnc') +
{{ __('Rechnung') }}
+ @elseif ($clearingtype === 'vor') +
{{ __('Vorkasse') }}
+ @endif +
+ @if ((int) $order->shipped === 0) + {{ __('offen') }} + @else + {{ $order->getShippedType() }} + @endif + + @if ($order->isApiOrder()) + @if ($order->wp_invoice_path) + + {{ __('Extern') }} + + @else + {{ __('extern') }} + @endif + @elseif (\App\Services\Invoice::isInvoice($order)) + {{ __('Re.') }} + {{ __('LS') }} + + @else +
+ @csrf + + +
+ @endif +
+ @if ((int) $order->shipped === 0) +
+ @csrf + + + +
+ @endif + @if (in_array((int) $order->shipped, [0, 1], true)) +
+ @csrf + + + +
+ @endif +
+ {{ __('Packliste') }} +
    + @foreach ($order->shopping_order_items as $item) +
  • + {{ $item->qty }} × {{ $item->product?->name ?? __('(Produkt gelöscht)') }} + @if ($item->product?->is_set) + SET +
      + @foreach ($item->product->setItems as $component) +
    • {{ $item->qty * $component->pivot->quantity }} × {{ $component->name }}
    • + @endforeach +
    + @endif +
  • + @endforeach +
+
+
+
+ {{ __('Nichts zu tun — keine Bestellungen in diesem Filter.') }} +
+
+
+ @if (($listLimit ?? null) !== null && $orders->count() >= $listLimit) +

+ {{ __('Es werden die neuesten :n Bestellungen angezeigt.', ['n' => $listLimit]) }} + {{ __('Alle anzeigen') }} +

+ @endif +
+
+ + +@endsection diff --git a/resources/views/layouts/includes/layout-sidenav.blade.php b/resources/views/layouts/includes/layout-sidenav.blade.php index c809992..ef5545c 100644 --- a/resources/views/layouts/includes/layout-sidenav.blade.php +++ b/resources/views/layouts/includes/layout-sidenav.blade.php @@ -256,11 +256,25 @@
{{ __('Ausgang / Ausschuss') }}
-
  • - -
    {{ __('API-Bestellungen') }}
    +
  • + + +
    {{ __('Bestellungen') }}
    +
  • diff --git a/routes/web.php b/routes/web.php index 187f4b5..64bf94c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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'); }); }); diff --git a/tests/Feature/OrderFulfillmentTest.php b/tests/Feature/OrderFulfillmentTest.php new file mode 100644 index 0000000..7f070c6 --- /dev/null +++ b/tests/Feature/OrderFulfillmentTest.php @@ -0,0 +1,264 @@ +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 $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'); +});