gruene-seele/app/Http/Controllers/Admin/Inventory/StockDisposalController.php
Kevin Adametz db27f040e6 Warenwirtschaft: F-09 — Ausschuss ohne Charge standort- und chargenkonsistent
Ohne Charge wird gegen den Restbestand des gewählten Lagerorts geprüft und die
Menge FEFO auf die dort verfügbaren Chargen verteilt (je Charge gebucht, in
einer Transaktion) — wie der Produktionsverbrauch. Gesamt-, Standort- und
Chargenbestand stimmen damit überein.

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

193 lines
6.9 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\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,
]);
}
public function store(StoreStockDisposalRequest $request, ProductionService $productionService): RedirectResponse
{
$data = $request->validatedPayload();
$data['user_id'] = (int) $request->user()->id;
DB::transaction(function () use ($data, $productionService): void {
foreach ($this->splitAcrossBatches($data, $productionService) as $row) {
StockDisposal::query()->create($row);
}
});
\Session::flash('alert-save', '1');
return redirect()->route('admin.inventory.stock-disposals.index');
}
/**
* 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();
}
}