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:
parent
f1eb5fbc7f
commit
a367d851f6
9 changed files with 818 additions and 71 deletions
|
|
@ -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.'"');
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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] : '';
|
||||
|
|
|
|||
87
app/Services/OrderStatusService.php
Normal file
87
app/Services/OrderStatusService.php
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
237
resources/views/admin/inventory/orders/index.blade.php
Normal file
237
resources/views/admin/inventory/orders/index.blade.php
Normal 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
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
264
tests/Feature/OrderFulfillmentTest.php
Normal file
264
tests/Feature/OrderFulfillmentTest.php
Normal 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');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue