- N-2: Letzter Netto-Preis je Lieferant nur aus tatsächlichen Eingängen (status received). - N-3: Verbrauchsfenster exakt (kein startOfDay-Off-by-one). - N-4: Kritisch-Zählung vereinheitlicht — Controller liefert die Kachel-Zählweise (inkl. critical_ordered), tote View-Überschreibung entfernt; Sidenav-Badge bleibt bewusst ohne bestellte Kritische. - N-5: Ausschuss-Liste zeigt das 500er-Limit an + "Alle anzeigen" (?all=1), analog OPT-3. - N-7: parseQuantity parst Strings konsequent im deutschen Format — Server und UI-Ampel interpretieren "1.500" jetzt identisch. - N-8: Produktions-Kopie übernimmt keine alten Chargen-Nummern mehr (Auto-Vorschlag Präfix+ttmmjj greift). - N-9: recordProductionStock lädt Produkte withTrashed — Korrektur- Gegenbuchungen soft-gelöschter Output-Produkte werden nicht mehr übersprungen. - N-15: Hinweise/Leitfaden-Markdown mit html_input=strip + allow_unsafe_links=false gerendert. Bewusst offen (Kundenentscheid/Design): N-1 (Chargen-Gruppierung ohne supplier_id), N-6 (reFormatNumber-Konvention app-weit), N-10 (manuelle Bewegung darf ins Minus, Inventur-Korrekturen), N-13 (mfg_row_phase- Fallback auf phasenlos). Suite: 231 grün, 823 Assertions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
148 lines
4.9 KiB
PHP
148 lines
4.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 Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Request;
|
|
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): RedirectResponse
|
|
{
|
|
$data = $request->validatedPayload();
|
|
$data['user_id'] = (int) $request->user()->id;
|
|
|
|
StockDisposal::query()->create($data);
|
|
|
|
\Session::flash('alert-save', '1');
|
|
|
|
return redirect()->route('admin.inventory.stock-disposals.index');
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
}
|
|
}
|