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.'"');
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue