gruene-seele/app/Http/Controllers/Admin/Inventory/StockEntryController.php
Kevin Adametz 9b6c9def97 Warenwirtschaft: N-1 — Chargen-Gruppierung je Lieferant (Kundenentscheid 15.07.2026)
Chargennummern kommen von extern und können kollidieren. Der Lieferant
gehört jetzt zum Gruppenschlüssel (batchGroupKey b:<supplier>:<lot>) —
gleiche Lot-Nummern verschiedener Lieferanten verschmelzen nicht mehr
(kein gemeinsames min-MHD/Summen-Rest). Batch-Check (AP-27) prüft
zusätzlich den Lieferanten des zu buchenden Eingangs.

Tests: +1 (getrennte Optionen je Lieferant); Chargen-Tests auf neues
Schlüsselformat umgestellt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 13:25:35 +00:00

339 lines
12 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\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',
]);
$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',
]);
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');
}
if (! $stockEntry->isPending()) {
\Session::flash('alert-error', __('Nur offene Bestellungen können bearbeitet werden.'));
return redirect()->route('admin.inventory.stock-entries.show', $stockEntry);
}
$stockEntry->load(['ingredient', 'packagingItem']);
return view('admin.inventory.stock-entries.edit', array_merge($this->formSharedData(), [
'model' => $stockEntry,
]));
}
public function update(UpdateStockEntryRequest $request, StockEntry $stockEntry): RedirectResponse
{
if (! $request->user()->isAdmin()) {
return redirect()->route('home');
}
if (! $stockEntry->isPending()) {
\Session::flash('alert-error', __('Nur offene Bestellungen können bearbeitet werden.'));
return redirect()->route('admin.inventory.stock-entries.show', $stockEntry);
}
$this->stockEntryRepository->update($stockEntry, $request->validatedPayload());
\Session::flash('alert-save', '1');
return 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';
$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;
}
$this->stockEntryRepository->receive($stockEntry, $data);
\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' => round((float) $remaining, 2),
'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']);
$results = $rows->map(function (Ingredient $i) {
$text = $i->name;
if ($i->inci) {
$text .= ' ('.$i->inci.')';
}
return ['id' => $i->id, 'text' => $text];
})->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'),
],
];
}
}