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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue