gruene-seele/app/Http/Controllers/Admin/Inventory/StockEntryController.php
Kevin Adametz 736cb087ae Warenwirtschaft: Einkäufe auch nach Buchung korrigierbar, mit Änderungsprotokoll
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>
2026-09-14 14:51:27 +00:00

357 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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'),
],
];
}
}