Bisher ließen sich nur offene Bestellungen bearbeiten; ein Tippfehler in einem gebuchten Wareneingang war nicht korrigierbar (Anforderung 14.09.2026). - Bearbeiten/Korrigieren für gebuchte Eingänge (Admin): Bestell- und Eingangsdaten (Menge, Datum, Charge, MHD, Qualität, Preise, Lieferant, Lagerort). Menge wirkt sofort auf den Bestand; ml-Eingänge werden in ml korrigiert und über die gespeicherte Dichte in Gramm gebucht. - Schutz: Menge nicht unter den bereits verbrauchten Anteil (Rohstoff je Eingang, Verpackung je Artikel); bei Verbrauch sind Art und Rohstoff/ Artikel gesperrt (Formular + Server). - Änderungsprotokoll (stock_entry_changes): wer/wann/Grund + geänderte Felder alt → neu; Anzeige auf der Detailseite. Grund bei gebuchten Eingängen Pflicht, bei offenen optional. Speichern ohne Änderung erzeugt keine Zeile. - Tests (6 neue), In-App-Doku (Hinweise + Leitfaden 3.2) nachgezogen. Migration: 2026_09_14_110000_create_stock_entry_changes_table. Suite: 396 grün. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
357 lines
13 KiB
PHP
357 lines
13 KiB
PHP
<?php
|
||
|
||
namespace App\Http\Controllers\Admin\Inventory;
|
||
|
||
use App\Http\Controllers\Controller;
|
||
use App\Http\Requests\Inventory\ReceiveStockEntryRequest;
|
||
use App\Http\Requests\Inventory\StoreStockEntryRequest;
|
||
use App\Http\Requests\Inventory\UpdateStockEntryRequest;
|
||
use App\Models\Ingredient;
|
||
use App\Models\Location;
|
||
use App\Models\MaterialQuality;
|
||
use App\Models\PackagingItem;
|
||
use App\Models\StockEntry;
|
||
use App\Models\Supplier;
|
||
use App\Models\TaxRate;
|
||
use App\Repositories\StockEntryRepository;
|
||
use App\Services\DeletionGuard;
|
||
use App\Services\InventoryService;
|
||
use Illuminate\Http\JsonResponse;
|
||
use Illuminate\Http\RedirectResponse;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Validation\ValidationException;
|
||
use Illuminate\View\View;
|
||
|
||
class StockEntryController extends Controller
|
||
{
|
||
public function __construct(
|
||
protected StockEntryRepository $stockEntryRepository
|
||
) {}
|
||
|
||
public function index(Request $request): View
|
||
{
|
||
$limit = $request->boolean('all') ? null : 500;
|
||
|
||
return view('admin.inventory.stock-entries.index', array_merge($this->formSharedData(), [
|
||
'values' => $this->stockEntryRepository->listForIndex($limit),
|
||
'listLimit' => $limit,
|
||
]));
|
||
}
|
||
|
||
public function create(Request $request): View|RedirectResponse
|
||
{
|
||
if (! auth()->user()->isAdmin()) {
|
||
return redirect()->route('home');
|
||
}
|
||
|
||
$model = new StockEntry([
|
||
'ordered_at' => now()->toDateString(),
|
||
'entry_type' => 'ingredient',
|
||
// AP-50/F-17: Köln (aktiv) als Standard-Lagerort — zentral in Location::defaultId().
|
||
'location_id' => Location::defaultId(),
|
||
]);
|
||
|
||
$ingredientId = (int) $request->query('ingredient_id');
|
||
if ($ingredientId > 0) {
|
||
$ingredient = Ingredient::query()->find($ingredientId);
|
||
if ($ingredient) {
|
||
$model->entry_type = 'ingredient';
|
||
$model->ingredient_id = $ingredient->id;
|
||
$model->setRelation('ingredient', $ingredient);
|
||
}
|
||
}
|
||
|
||
return view('admin.inventory.stock-entries.create', array_merge($this->formSharedData(), [
|
||
'model' => $model,
|
||
]));
|
||
}
|
||
|
||
public function store(StoreStockEntryRequest $request): RedirectResponse
|
||
{
|
||
if (! $request->user()->isAdmin()) {
|
||
return redirect()->route('home');
|
||
}
|
||
|
||
$data = $request->validatedPayload();
|
||
$data['ordered_by'] = (int) auth()->id();
|
||
|
||
$this->stockEntryRepository->create($data);
|
||
|
||
\Session::flash('alert-save', '1');
|
||
|
||
return redirect()->route('admin.inventory.stock-entries.index');
|
||
}
|
||
|
||
public function show(StockEntry $stockEntry): View
|
||
{
|
||
$stockEntry->load([
|
||
'ingredient',
|
||
'packagingItem.packagingMaterial',
|
||
'supplier',
|
||
'location',
|
||
'quality',
|
||
'orderedByUser.account',
|
||
'receivedByUser.account',
|
||
'changes.user',
|
||
]);
|
||
|
||
return view('admin.inventory.stock-entries.show', array_merge($this->formSharedData(), [
|
||
'model' => $stockEntry,
|
||
'materialQualities' => MaterialQuality::query()->orderBy('pos')->orderBy('name')->get(),
|
||
]));
|
||
}
|
||
|
||
public function edit(StockEntry $stockEntry): View|RedirectResponse
|
||
{
|
||
if (! auth()->user()->isAdmin()) {
|
||
return redirect()->route('home');
|
||
}
|
||
|
||
// Anforderung 14.09.2026: auch gebuchte Eingänge sind korrigierbar (mit Protokoll).
|
||
$stockEntry->load(['ingredient', 'packagingItem']);
|
||
|
||
return view('admin.inventory.stock-entries.edit', array_merge($this->formSharedData(), [
|
||
'model' => $stockEntry,
|
||
'correctionBlockers' => $stockEntry->isReceived() ? app(DeletionGuard::class)->blockersFor($stockEntry) : [],
|
||
]));
|
||
}
|
||
|
||
public function update(UpdateStockEntryRequest $request, StockEntry $stockEntry): RedirectResponse
|
||
{
|
||
if (! $request->user()->isAdmin()) {
|
||
return redirect()->route('home');
|
||
}
|
||
|
||
try {
|
||
$this->stockEntryRepository->update($stockEntry, $request->validatedPayload(), $request->input('change_reason'));
|
||
} catch (ValidationException $e) {
|
||
return redirect()->route('admin.inventory.stock-entries.edit', $stockEntry)->withErrors($e->errors())->withInput();
|
||
}
|
||
|
||
\Session::flash('alert-save', '1');
|
||
|
||
return $stockEntry->isReceived()
|
||
? redirect()->route('admin.inventory.stock-entries.show', $stockEntry)
|
||
: redirect()->route('admin.inventory.stock-entries.index');
|
||
}
|
||
|
||
public function copy(StockEntry $stockEntry): RedirectResponse
|
||
{
|
||
if (! auth()->user()->isAdmin()) {
|
||
return redirect()->route('home');
|
||
}
|
||
|
||
$data = $stockEntry->only([
|
||
'entry_type',
|
||
'ingredient_id',
|
||
'packaging_item_id',
|
||
'supplier_id',
|
||
'location_id',
|
||
'ordered_quantity',
|
||
'price_per_kg',
|
||
'price_per_kg_gross',
|
||
'price_total',
|
||
'tax_rate_id',
|
||
'tax_rate_percent',
|
||
'quality_id',
|
||
]);
|
||
$data['ordered_at'] = now()->toDateString();
|
||
$data['ordered_by'] = (int) auth()->id();
|
||
$data['status'] = 'pending';
|
||
|
||
// Prüfung 08.09.2026 F-15: In ml erfasste Bestellungen als ml kopieren — Menge
|
||
// zurück in ml (über den Dichte-Snapshot), Einheit und Dichte mitgeben. Sonst wird
|
||
// die Kopie zur Gramm-Bestellung und ein Wareneingang „100“ bucht 100 g statt 79 g.
|
||
if ($stockEntry->purchase_unit === 'ml' && $stockEntry->density_used !== null && (float) $stockEntry->density_used > 0) {
|
||
$data['ordered_unit'] = 'ml';
|
||
$data['ordered_quantity'] = roundWawiQuantity((float) $stockEntry->ordered_quantity / (float) $stockEntry->density_used);
|
||
$data['density_used'] = (float) $stockEntry->density_used;
|
||
}
|
||
|
||
$copy = $this->stockEntryRepository->create($data);
|
||
|
||
\Session::flash('alert-warning', __('Kopie angelegt – bitte Menge/Charge prüfen und speichern.'));
|
||
|
||
return redirect()->route('admin.inventory.stock-entries.edit', $copy);
|
||
}
|
||
|
||
public function destroy(StockEntry $stockEntry): RedirectResponse
|
||
{
|
||
if (! auth()->user()->isAdmin()) {
|
||
return redirect()->route('home');
|
||
}
|
||
|
||
// Anforderung 15.07.2026: auch gebuchte Eingänge löschbar (Soft-Delete),
|
||
// sofern keine Abhängigkeiten (Produktions-Verbrauch, Ausschuss) bestehen.
|
||
$guard = app(DeletionGuard::class);
|
||
$blockers = $guard->blockersFor($stockEntry);
|
||
if ($blockers !== []) {
|
||
\Session::flash('alert-error', $guard->message($blockers));
|
||
|
||
return redirect()->route('admin.inventory.stock-entries.index');
|
||
}
|
||
|
||
$stockEntry->delete();
|
||
|
||
\Session::flash('alert-success', __('Eintrag gelöscht'));
|
||
|
||
return redirect()->route('admin.inventory.stock-entries.index');
|
||
}
|
||
|
||
public function receive(ReceiveStockEntryRequest $request, StockEntry $stockEntry): RedirectResponse
|
||
{
|
||
if (! $stockEntry->isPending()) {
|
||
\Session::flash('alert-error', __('Eintrag nicht mehr offen.'));
|
||
|
||
return redirect()->route('admin.inventory.stock-entries.show', $stockEntry);
|
||
}
|
||
|
||
$data = $request->validated();
|
||
if ($stockEntry->entry_type !== 'ingredient') {
|
||
$data['quality_id'] = null;
|
||
$data['best_before'] = null;
|
||
}
|
||
|
||
try {
|
||
$this->stockEntryRepository->receive($stockEntry, $data);
|
||
} catch (ValidationException $e) {
|
||
\Session::flash('alert-error', $e->errors()['status'][0] ?? __('Eintrag nicht mehr offen.'));
|
||
|
||
return redirect()->route('admin.inventory.stock-entries.show', $stockEntry);
|
||
}
|
||
|
||
\Session::flash('alert-save', '1');
|
||
|
||
return redirect()->route('admin.inventory.stock-entries.show', $stockEntry->fresh());
|
||
}
|
||
|
||
/**
|
||
* AP-27: Existenz-Check beim Buchen des Wareneingangs — ist die Charge
|
||
* (batch_number + ingredient_id) schon im Haus? Liefert Hinweis-Daten
|
||
* und Vorbelegungswerte (MHD/Qualität) aus dem letzten Eingang der Charge.
|
||
*/
|
||
public function batchCheck(Request $request, InventoryService $inventoryService): JsonResponse
|
||
{
|
||
$ingredientId = (int) $request->query('ingredient_id');
|
||
$batchNumber = trim((string) $request->query('batch_number', ''));
|
||
$excludeId = (int) $request->query('exclude', 0);
|
||
// Audit N-1: Chargen sind je Lieferant getrennt — gleiche Lot-Nummer
|
||
// eines anderen Lieferanten ist keine vorhandene Charge.
|
||
$supplierId = (int) $request->query('supplier_id', 0);
|
||
|
||
if ($ingredientId < 1 || $batchNumber === '') {
|
||
return response()->json(['exists' => false]);
|
||
}
|
||
|
||
$entries = StockEntry::query()
|
||
->with('location')
|
||
->where('entry_type', 'ingredient')
|
||
->where('status', 'received')
|
||
->where('ingredient_id', $ingredientId)
|
||
->where('batch_number', $batchNumber)
|
||
->when($supplierId > 0, fn ($q) => $q->where('supplier_id', $supplierId))
|
||
->when($excludeId > 0, fn ($q) => $q->where('id', '!=', $excludeId))
|
||
->orderByDesc('received_at')
|
||
->orderByDesc('id')
|
||
->get();
|
||
|
||
if ($entries->isEmpty()) {
|
||
return response()->json(['exists' => false]);
|
||
}
|
||
|
||
$latest = $entries->first();
|
||
$remaining = $inventoryService->chargesWithRemainingForIngredient($ingredientId)
|
||
->filter(fn (StockEntry $e) => trim((string) $e->batch_number) === $batchNumber
|
||
&& ($supplierId === 0 || (int) $e->supplier_id === $supplierId)
|
||
&& $e->id !== $excludeId)
|
||
->sum(fn (StockEntry $e) => (float) $e->getAttribute('remaining_quantity'));
|
||
|
||
return response()->json([
|
||
'exists' => true,
|
||
'purchases' => $entries->count(),
|
||
'remaining' => roundWawiQuantity((float) $remaining),
|
||
'best_before' => $latest->best_before?->format('d.m.Y'),
|
||
'location' => $latest->location?->name,
|
||
'quality_id' => $latest->quality_id,
|
||
]);
|
||
}
|
||
|
||
public function searchIngredients(Request $request): JsonResponse
|
||
{
|
||
$term = trim((string) $request->query('q', ''));
|
||
if (mb_strlen($term) < 1) {
|
||
return response()->json(['results' => []]);
|
||
}
|
||
|
||
$rows = Ingredient::query()
|
||
->where('active', true)
|
||
->where(function ($query) use ($term) {
|
||
$query->where('name', 'like', '%'.$term.'%')
|
||
->orWhere('inci', 'like', '%'.$term.'%');
|
||
})
|
||
->orderBy('name')
|
||
->limit(30)
|
||
->get(['id', 'name', 'inci', 'density']);
|
||
|
||
$results = $rows->map(function (Ingredient $i) {
|
||
$text = $i->name;
|
||
if ($i->inci) {
|
||
$text .= ' ('.$i->inci.')';
|
||
}
|
||
|
||
// AP-42: Dichte mitliefern, damit die Maske die ml-Erfassung anbieten kann.
|
||
return ['id' => $i->id, 'text' => $text, 'density' => $i->density !== null ? (float) $i->density : null];
|
||
})->values()->all();
|
||
|
||
return response()->json(['results' => $results]);
|
||
}
|
||
|
||
public function searchPackagingItems(Request $request): JsonResponse
|
||
{
|
||
$term = trim((string) $request->query('q', ''));
|
||
$entryType = $request->query('entry_type');
|
||
|
||
$categoryMap = [
|
||
'packaging' => 'packaging',
|
||
'shipping' => 'shipping',
|
||
];
|
||
|
||
$query = PackagingItem::query()
|
||
->where('active', true);
|
||
|
||
if ($entryType && isset($categoryMap[$entryType])) {
|
||
$query->where('category', $categoryMap[$entryType]);
|
||
}
|
||
|
||
if (mb_strlen($term) >= 1) {
|
||
$query->where('name', 'like', '%'.$term.'%');
|
||
}
|
||
|
||
$rows = $query->orderBy('name')->limit(30)->get(['id', 'name']);
|
||
|
||
$results = $rows->map(fn (PackagingItem $p) => [
|
||
'id' => $p->id,
|
||
'text' => $p->name,
|
||
])->values()->all();
|
||
|
||
return response()->json(['results' => $results]);
|
||
}
|
||
|
||
/**
|
||
* @return array<string, mixed>
|
||
*/
|
||
protected function formSharedData(): array
|
||
{
|
||
return [
|
||
'suppliers' => Supplier::query()->where('active', true)->orderBy('name')->get(),
|
||
'locations' => Location::query()->where('active', true)->orderBy('name')->get(),
|
||
'materialQualities' => MaterialQuality::query()->orderBy('pos')->orderBy('name')->get(),
|
||
'taxRates' => TaxRate::query()->active()->orderBy('pos')->orderBy('name')->get(),
|
||
'entryTypeLabels' => [
|
||
'ingredient' => __('Rohstoff'),
|
||
'packaging' => __('Produktverpackung'),
|
||
'shipping' => __('Versandverpackung'),
|
||
],
|
||
];
|
||
}
|
||
}
|