gruene-seele/app/Http/Controllers/Admin/Inventory/StockDisposalController.php
Kevin Adametz 313c6277ce Warenwirtschaft: N-01/N-03/N-05 — Entwurfsmenge ohne Charge, Snapshot-Signatur je Produkt, Alt-Ausschuss in Chargenverfügbarkeit
- N-01: Ist-Menge ohne gewählte Charge bleibt im Entwurf erhalten (Filter in
  Request und Service, formBatchKey liefert '' für chargenlose Zeilen).
- N-03: outputSignature summiert Stückzahlen je Produkt — ein Chargensplit
  ersetzt den historischen Verpackungs-Snapshot nicht mehr.
- N-05: Ausschuss ohne Chargenbezug (Altbestand) wird für die Verfügbarkeit je
  Standort FEFO auf die Chargen angerechnet (applyUnboundDisposals, lesend);
  Chargen-Ausschussprüfung nutzt dieselbe Verfügbarkeit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 11:48:02 +00:00

242 lines
9.2 KiB
PHP

<?php
namespace App\Http\Controllers\Admin\Inventory;
use App\Http\Controllers\Controller;
use App\Http\Requests\Inventory\StoreStockDisposalRequest;
use App\Models\DisposalReason;
use App\Models\Ingredient;
use App\Models\Location;
use App\Models\StockDisposal;
use App\Models\StockEntry;
use App\Services\InventoryService;
use App\Services\ProductionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class StockDisposalController extends Controller
{
public function __construct(
protected InventoryService $inventoryService,
) {}
public function index(Request $request): View
{
$query = StockDisposal::query()
->with(['ingredient', 'packagingItem', 'location', 'stockEntry', 'user.account'])
->latest('disposed_at')
->latest('id');
if (in_array($request->query('type'), ['ingredient', 'packaging'], true)) {
$query->where('disposal_type', $request->query('type'));
}
// Audit N-5: Limit sichtbar machen statt still abzuschneiden (analog OPT-3).
$limit = $request->boolean('all') ? null : 500;
return view('admin.inventory.stock-disposals.index', [
'values' => $limit !== null ? $query->limit($limit)->get() : $query->get(),
'typeFilter' => $request->query('type'),
'listLimit' => $limit,
]);
}
public function create(Request $request): View|RedirectResponse
{
if (! auth()->user()->isAdmin()) {
return redirect()->route('home');
}
$prefill = [
'disposal_type' => 'ingredient',
'ingredient_id' => null,
'ingredient_label' => null,
];
$ingredientId = (int) $request->query('ingredient_id');
if ($ingredientId > 0 && ($ingredient = Ingredient::query()->find($ingredientId))) {
$prefill['ingredient_id'] = $ingredient->id;
$prefill['ingredient_label'] = $ingredient->inci ? $ingredient->name.' ('.$ingredient->inci.')' : $ingredient->name;
}
return view('admin.inventory.stock-disposals.create', [
'locations' => Location::query()->where('active', true)->orderBy('name')->get(),
'reasons' => $this->reasons(),
'prefill' => $prefill,
// AP-50/F-17: Köln auch beim Ausschuss vorbelegen.
'defaultLocationId' => Location::defaultId(),
]);
}
public function store(StoreStockDisposalRequest $request, ProductionService $productionService): RedirectResponse
{
$data = $request->validatedPayload();
$data['user_id'] = (int) $request->user()->id;
try {
DB::transaction(function () use ($data, $productionService): void {
$this->assertStillAvailable($data, $productionService);
foreach ($this->splitAcrossBatches($data, $productionService) as $row) {
StockDisposal::query()->create($row);
}
});
} catch (ValidationException $e) {
return redirect()->back()->withInput()->withErrors($e->errors());
}
\Session::flash('alert-save', '1');
return redirect()->route('admin.inventory.stock-disposals.index');
}
/**
* Prüfung 08.09.2026 F-16: Bestandsprüfung innerhalb der Transaktion wiederholen,
* nachdem die betroffenen Wareneingänge gesperrt wurden — die FormRequest-Prüfung
* allein lässt zwei parallele Entnahmen denselben Rest bestehen.
*
* @param array<string, mixed> $data
*
* @throws ValidationException
*/
private function assertStillAvailable(array $data, ProductionService $productionService): void
{
if (($data['disposal_type'] ?? null) !== 'ingredient') {
return;
}
$ingredientId = (int) $data['ingredient_id'];
$quantity = round((float) $data['quantity'], 2);
if (! empty($data['stock_entry_id'])) {
$entry = StockEntry::query()->lockForUpdate()->findOrFail((int) $data['stock_entry_id']);
$available = $productionService->availableStockEntriesForIngredient($ingredientId, (int) $entry->location_id)->firstWhere('id', $entry->id);
$remaining = $available !== null ? round((float) $available->getAttribute('remaining_quantity'), 2) : 0.0;
} else {
// Alle Eingänge des Rohstoffs am Standort sperren, dann Standortrest neu berechnen.
StockEntry::query()->lockForUpdate()
->where('entry_type', 'ingredient')->where('ingredient_id', $ingredientId)
->where('location_id', (int) $data['location_id'])
->get();
$remaining = round((float) ($this->inventoryService->remainingByLocationForIngredient($ingredientId)[(int) $data['location_id']] ?? 0.0), 2);
}
if ($quantity > $remaining) {
throw ValidationException::withMessages([
'quantity' => __('Die Menge (:qty g) übersteigt den Restbestand (:rest g) — Bestand hat sich zwischenzeitlich geändert.', [
'qty' => number_format($quantity, 2, ',', '.'),
'rest' => number_format(max(0, $remaining), 2, ',', '.'),
]),
]);
}
}
/**
* Prüfung 08.09.2026 F-09: Rohstoff-Ausschuss ohne bestimmte Charge wird — wie der
* Produktionsverbrauch (AP-27, FEFO) — auf die am Lagerort verfügbaren Chargen
* verteilt und je Charge gebucht. Sonst sinkt nur der Gesamtbestand, während die
* Chargen in Produktion/Ausschuss weiter als voll verfügbar angeboten würden.
* Ohne verfügbare Charge (Altbestand ohne Chargenbezug) bleibt die Buchung ungebunden.
*
* @param array<string, mixed> $data
* @return array<int, array<string, mixed>>
*/
private function splitAcrossBatches(array $data, ProductionService $productionService): array
{
if (($data['disposal_type'] ?? null) !== 'ingredient' || ! empty($data['stock_entry_id'])) {
return [$data];
}
$entries = $productionService->availableStockEntriesForIngredient((int) $data['ingredient_id'], (int) $data['location_id']);
if ($entries->isEmpty()) {
return [$data];
}
$rows = [];
$needed = round((float) $data['quantity'], 2);
$lastIndex = $entries->count() - 1;
foreach ($entries as $index => $entry) {
$capacity = (float) $entry->getAttribute('remaining_quantity');
$take = $index === $lastIndex ? $needed : round(min($needed, $capacity), 2);
if ($take > 0) {
$rows[] = array_merge($data, ['stock_entry_id' => (int) $entry->id, 'quantity' => $take]);
}
$needed = round($needed - $take, 2);
if ($needed <= 0) {
break;
}
}
return $rows;
}
/**
* Anforderung 15.07.2026: Ausschuss-Buchungen löschbar (Soft-Delete).
* Die Bestandsaggregationen schließen gelöschte Buchungen automatisch
* aus — der Bestand steht danach wieder zur Verfügung.
*/
public function destroy(StockDisposal $stockDisposal): RedirectResponse
{
if (! auth()->user()->isAdmin()) {
return redirect()->route('home');
}
$stockDisposal->delete();
\Session::flash('alert-success', __('Ausschuss-Buchung gelöscht — die Menge steht dem Bestand wieder zur Verfügung.'));
return redirect()->route('admin.inventory.stock-disposals.index');
}
public function ingredientCharges(Ingredient $ingredient): JsonResponse
{
$remainingByLocation = $this->inventoryService->remainingByLocationForIngredient($ingredient->id);
$charges = StockEntry::query()
->where('status', 'received')
->where('entry_type', 'ingredient')
->where('ingredient_id', $ingredient->id)
->with('location')
->orderBy('best_before')
->get(['id', 'location_id', 'batch_number', 'best_before', 'received_quantity']);
$results = $charges->map(function (StockEntry $charge) {
$label = $charge->batch_number ? __('Charge').' '.$charge->batch_number : __('Charge #:id', ['id' => $charge->id]);
if ($charge->location) {
$label .= ' · '.$charge->location->name;
}
if ($charge->best_before) {
$label .= ' · MHD '.$charge->best_before->format('d.m.Y');
}
return [
'id' => $charge->id,
'location_id' => $charge->location_id,
'text' => $label,
];
})->values()->all();
return response()->json([
'charges' => $results,
'remaining_by_location' => $remainingByLocation,
]);
}
/**
* Aktive Ausschuss-Gründe aus den Einstellungen (Warenwirtschaft → Allgemein).
*
* @return array<int, string>
*/
protected function reasons(): array
{
return DisposalReason::query()
->active()
->orderBy('pos')
->orderBy('label')
->pluck('label')
->all();
}
}