gruene-seele/app/Http/Controllers/Admin/Inventory/StockEntryController.php
Kevin Adametz 35b9076cbc Warenwirtschaft: AP-31 Löschen mit Soft-Delete + Abhängigkeitsprüfung
Anforderung 15.07.2026: Nahezu alle WaWi-Einträge sind jetzt löschbar
(Soft-Delete, deleted_at), mit Abhängigkeitsprüfung und Bestätigungs-
warnung vor jeder Löschung.

- Migration: deleted_at auf 14 WaWi-Tabellen (Stammdaten, ingredients,
  stock_entries, stock_disposals, productions); SoftDeletes-Trait in
  den Models (suppliers/packaging_items hatten es bereits).
- Neuer App\Services\DeletionGuard: zentrale Abhängigkeitsprüfung je
  Entität — referenzierte Einträge sind nicht löschbar, Fehlermeldung
  nennt die Verwendungen (behebt auch Audit M-4: kein 500er/stiller
  Cascade-Verlust mehr beim Löschen verwendeter Stammdaten).
- SettingCrudController::destroy() prüft Blocker; Supplier-,
  PackagingItem-, Ingredient-destroy ebenso (Ingredient: vollständige
  Prüfung inkl. Produktion/Ausschuss, Route GET→DELETE, Audit M-6).
- Wareneingänge: auch gebuchte Eingänge löschbar, sofern kein
  Produktions-Verbrauch/Ausschuss darauf gebucht ist.
- Ausschuss: löschbar (Menge steht dem Bestand wieder zur Verfügung).
- Produktionen: löschbar (admin >= 7); Produktbestand wird per
  Gegenbuchung zurückgeführt (reverseProductionStock, append-only,
  withTrashed), Rohstoffverbrauch zählt nicht mehr (Bestandsqueries
  schließen gelöschte Produktionen aus).
- lastNetPricePerSupplier: DB::query statt Model-Query (Global Scope
  auf Sub-Select-Alias).
- Debug-Log-Reste im PackagingItemController entfernt (Audit N-16).
- Views: Lösch-Buttons mit deutschen Bestätigungswarnungen (Ausschuss,
  Produktionen, gebuchte Wareneingänge, INCI als DELETE-Formular).

Tests: neuer InventoryDeletionTest (10 Tests), StorageSettingsTest an
neue Löschregel angepasst. Volle Suite: 214 grün, 783 Assertions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 12:31:11 +00:00

333 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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);
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($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 && $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']);
$results = $rows->map(function (Ingredient $i) {
$text = $i->name;
if ($i->inci) {
$text .= ' ('.$i->inci.')';
}
return ['id' => $i->id, 'text' => $text];
})->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'),
],
];
}
}