- Statusservice: Vorbedingung (Ausgangsstatus, Zahlungsfreigabe) wird nach lockForUpdate erneut geprüft (Board übergibt sie als Closure). - Wareneingang: Transaktion + Sperre, „offen“ wird innerhalb der Sperre geprüft. - Ausschuss: betroffene Wareneingänge sperren und Restbestand in der Transaktion erneut prüfen. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
252 lines
11 KiB
PHP
252 lines
11 KiB
PHP
<?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;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
/**
|
|
* 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.
|
|
// Bewusst OHNE Limit (Review 20.07.2026): das Cap galt vorher schon für die Zähler
|
|
// und Filter-Listen, wodurch bei > 500 offenen Bestellungen ausgerechnet die
|
|
// ältesten versandfertigen unsichtbar wurden — und der Hinweis-Banner verglich
|
|
// den Post-Filter-Count, erschien also nie. Gekappt wird jetzt nur die Anzeige.
|
|
$open = ShoppingOrder::query()
|
|
->with($withRelations)
|
|
->whereIn('shipped', [0, 1])
|
|
->orderByDesc('id')
|
|
->get();
|
|
|
|
$pick = $open->where('shipped', 0)->filter->isReadyToShip()->values();
|
|
$ready = $open->where('shipped', 1)->filter->isReadyToShip()->values();
|
|
|
|
$counts = [
|
|
'today' => ShoppingOrder::query()->whereBetween('created_at', [today(), today()->endOfDay()])->count(),
|
|
'pick' => $pick->count(),
|
|
'ready' => $ready->count(),
|
|
'shipped_today' => ShoppingOrder::query()->whereBetween('shipped_at', [today(), today()->endOfDay()])->count(),
|
|
];
|
|
|
|
$orders = match ($filter) {
|
|
'today' => ShoppingOrder::query()->with($withRelations)->whereBetween('created_at', [today(), today()->endOfDay()])->orderByDesc('id')->get(),
|
|
'pick' => $pick,
|
|
'ready' => $ready,
|
|
'shipped_today' => ShoppingOrder::query()->with($withRelations)->whereBetween('shipped_at', [today(), today()->endOfDay()])->orderByDesc('id')->get(),
|
|
default => $open,
|
|
};
|
|
|
|
$listTotal = $orders->count();
|
|
if ($limit !== null) {
|
|
$orders = $orders->take($limit)->values();
|
|
}
|
|
|
|
return view('admin.inventory.orders.index', [
|
|
'orders' => $orders,
|
|
'counts' => $counts,
|
|
'filter' => $filter,
|
|
'listLimit' => $limit,
|
|
'listTotal' => $listTotal,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 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])],
|
|
]);
|
|
|
|
$shipped = (int) $request->input('shipped');
|
|
$backUrl = route('admin.inventory.orders.index', array_filter(['filter' => $request->input('back_filter')]));
|
|
|
|
// Fachliche Vorbedingungen — Prüfung 08.09.2026 F-16: werden vom Statusservice
|
|
// innerhalb der Zeilensperre erneut geprüft, nicht nur hier vorab.
|
|
$precondition = function (ShoppingOrder $order) use ($shipped): ?string {
|
|
if (! in_array((int) $order->shipped, [0, 1], true)) {
|
|
return __('Bestellung ist nicht mehr in der Abwicklung (Status: :status).', ['status' => $order->getShippedType()]);
|
|
}
|
|
// Prüfung 08.09.2026 F-10: „versendet“ (Bestandsabzug) nur für versandfertige
|
|
// Bestellungen — Zahlung eingegangen bzw. Rechnung/API (Kundenentscheid 16.07.2026,
|
|
// isReadyToShip). Konfektionieren bleibt auch vor Zahlungseingang möglich.
|
|
if ($shipped === 2 && ! $order->isReadyToShip()) {
|
|
return __('Bestellung #:id wartet auf Zahlung und kann noch nicht als versendet gebucht werden.', ['id' => $order->id]);
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
ShoppingOrder::query()->findOrFail($id);
|
|
|
|
try {
|
|
app(OrderStatusService::class)->setShipped($id, $shipped, auth()->id(), lockStatus: true, precondition: $precondition);
|
|
} catch (ValidationException $e) {
|
|
session()->flash('alert-error', $e->errors()['shipped'][0] ?? __('Statuswechsel nicht möglich.'));
|
|
|
|
return redirect()->to($backUrl);
|
|
}
|
|
|
|
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()->to($backUrl);
|
|
}
|
|
|
|
/**
|
|
* 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')]));
|
|
}
|
|
|
|
/**
|
|
* AP-33b: Sammel-Dokumente — Lieferscheine (optional inkl. Rechnungen) der
|
|
* ausgewählten Bestellungen als EIN zusammengeführtes PDF zum Stapeldruck.
|
|
* Fehlende Dokumente werden dabei erzeugt (ohne Mailversand); API-Bestellungen
|
|
* werden übersprungen (Rechnung liegt im externen Shop).
|
|
*/
|
|
public function batchDocuments(Request $request)
|
|
{
|
|
$data = $request->validate([
|
|
'order_ids' => ['required', 'array', 'min:1', 'max:100'],
|
|
'order_ids.*' => ['integer', 'exists:shopping_orders,id'],
|
|
'type' => ['required', Rule::in(['delivery', 'combined'])],
|
|
]);
|
|
|
|
$orders = ShoppingOrder::query()->whereIn('id', $data['order_ids'])->orderBy('id')->get();
|
|
$printable = $orders->reject(fn (ShoppingOrder $order) => $order->isApiOrder())->values();
|
|
|
|
if ($printable->isEmpty()) {
|
|
session()->flash('alert-error', __('Nur API-Bestellungen ausgewählt — deren Rechnungen liegen im externen Shop.'));
|
|
|
|
return redirect()->route('admin.inventory.orders.index');
|
|
}
|
|
|
|
foreach ($printable as $order) {
|
|
if (! Invoice::isInvoice($order)) {
|
|
(new InvoiceRepository($order))->create([]);
|
|
} else {
|
|
// Fehlende Dateien (Altdaten) mit bestehender Nummer regenerieren —
|
|
// prüft Rechnung UND Lieferschein (Review 20.07.2026)
|
|
(new InvoiceRepository($order))->ensureFilesOnDisk();
|
|
}
|
|
}
|
|
|
|
$filename = ($data['type'] === 'combined' ? 'Rechnungen-Lieferscheine-' : 'Lieferscheine-')
|
|
.now()->format('d-m-Y_Hi').'.pdf';
|
|
|
|
return $this->pdfResponse($this->mergedPdf($printable, $data['type'] === 'combined', $filename), $filename);
|
|
}
|
|
|
|
/**
|
|
* 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', 'label'], true), 404);
|
|
|
|
$order = ShoppingOrder::query()->findOrFail($id);
|
|
|
|
// AP-32 Brückenlösung: lokal gespeicherte Kopie des DHL-Labels aus dem
|
|
// WooCommerce-Shop (privater Storage — bewusst nicht die öffentliche Shop-URL)
|
|
if ($type === 'label') {
|
|
abort_unless($order->wp_label_path && Storage::disk('local')->exists($order->wp_label_path), 404);
|
|
|
|
return $this->pdfResponse(Storage::disk('local')->get($order->wp_label_path), 'DHL-Label-Bestellung-'.$order->id.'.pdf');
|
|
}
|
|
|
|
abort_unless(Invoice::isInvoice($order), 404);
|
|
// Fehlende Dateien (Altdaten) mit bestehender Nummer regenerieren — vorher
|
|
// lief der combined-Zweig ungeprüft in eine Exception (Review 20.07.2026)
|
|
abort_unless((new InvoiceRepository($order))->ensureFilesOnDisk(), 404);
|
|
|
|
if ($type === 'combined') {
|
|
$filename = str_replace('Rechnung-', 'Rechnung-Lieferschein-', Invoice::getFilename($order));
|
|
|
|
return $this->pdfResponse($this->mergedPdf([$order], true, $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 $this->pdfResponse(Storage::disk('public')->get($path), $filename);
|
|
}
|
|
|
|
/**
|
|
* Rechnung (optional) + Lieferschein der Bestellungen zu EINEM PDF mergen —
|
|
* gemeinsame Implementierung für Einzel-Kombi und Sammel-Druck.
|
|
*
|
|
* @param iterable<ShoppingOrder> $orders
|
|
*/
|
|
private function mergedPdf(iterable $orders, bool $withInvoice, string $filename): string
|
|
{
|
|
$merger = new PDFMerger;
|
|
$merger->init();
|
|
|
|
foreach ($orders as $order) {
|
|
if ($withInvoice) {
|
|
$merger->addPDF(Invoice::getDownloadPath($order, true));
|
|
}
|
|
$merger->addPDF(Invoice::getDownloadPathDelivery($order, true));
|
|
}
|
|
|
|
$merger->setFileName($filename);
|
|
$merger->merge();
|
|
|
|
return $merger->output();
|
|
}
|
|
|
|
private function pdfResponse(string $content, string $filename)
|
|
{
|
|
return Response::make($content, 200)
|
|
->header('Content-Type', 'application/pdf')
|
|
->header('Content-disposition', 'inline; filename="'.$filename.'"');
|
|
}
|
|
}
|