Kundenentscheid 26.08.: Rohstoffe werden in Gramm geführt, dürfen aber in ml erfasst werden; über die Dichte ergeben sich die Gramm (100 ml Propolistinktur × 0,79 = 79 g). - Neues Feld ingredients.density (g/ml, optional) inkl. Feld im INCI-Formular. - Wareneingang: Einheiten-Umschalter g/ml am Mengenfeld (nur wenn Dichte hinterlegt; Live-Hinweis mit Gramm-Umrechnung, Dichte kommt aus der Rohstoff-Suche mit). Erfassung in ml wird über die Dichte in Gramm umgerechnet und kanonisch als Gramm gespeichert — InventoryService und alle Aggregationen bleiben unverändert. purchase_unit + density_used (Snapshot) protokollieren die ml-Erfassung für Anzeige/Bearbeitung; Detailseite zeigt „790 g (= 1.000 ml)". Wareneingang-Buchung rechnet gelieferte ml ebenfalls um. - Serverseitige Validierung: ml nur mit hinterlegter Dichte. Migrationen add_density_* auf Dev-/Live-DB. 4 Tests, Suite 318 grün. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
340 lines
12 KiB
PHP
340 lines
12 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\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', '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'),
|
||
],
|
||
];
|
||
}
|
||
}
|