- Statusservice: Vorbedingung (Ausgangsstatus, Zahlungsfreigabe) wird nach lockForUpdate erneut geprüft (Board übergibt sie als Closure). - Wareneingang: Transaktion + Sperre, „offen“ wird innerhalb der Sperre geprüft. - Ausschuss: betroffene Wareneingänge sperren und Restbestand in der Transaktion erneut prüfen. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
242 lines
9.2 KiB
PHP
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']);
|
|
$consumed = $productionService->consumedByStockEntry([$entry->id])[$entry->id] ?? 0.0;
|
|
$remaining = round((float) $entry->received_quantity - $consumed, 2);
|
|
} 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();
|
|
}
|
|
}
|