gruene-seele/app/Http/Controllers/Admin/Inventory/ProductionController.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

218 lines
8.1 KiB
PHP

<?php
namespace App\Http\Controllers\Admin\Inventory;
use App\Http\Controllers\Controller;
use App\Http\Requests\Inventory\StoreProductionRequest;
use App\Models\Location;
use App\Models\Product;
use App\Models\Production;
use App\Repositories\ProductionRepository;
use App\Services\ProductionService;
use App\Services\ProductStockService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class ProductionController extends Controller
{
public function __construct(
protected ProductionRepository $productionRepository,
protected ProductionService $productionService
) {}
public function index(Request $request): View
{
$limit = $request->boolean('all') ? null : 500;
return view('admin.inventory.productions.index', [
'values' => $this->productionRepository->listForIndex($limit),
'listLimit' => $limit,
]);
}
public function create(Request $request): View
{
$defaultLocationId = Location::query()->where('name', 'like', '%öln%')->value('id')
?? Location::query()->where('active', true)->first()?->id;
$defaultProductId = (int) $request->query('product_id') ?: null;
return view('admin.inventory.productions.create', [
'products' => Product::query()->where('active', 1)->where('is_set', false)->orderBy('name')->get(['id', 'name', 'batch_prefix']),
'locations' => Location::query()->where('active', true)->orderBy('name')->get(),
'defaultLocationId' => $defaultLocationId,
'defaultProductId' => $defaultProductId,
'model' => null,
]);
}
public function store(StoreProductionRequest $request): RedirectResponse
{
$payload = $request->validatedPayload();
try {
// AP-27: Chargen-Zeilen (batch_key) FEFO auf die Einzel-Wareneingänge der Charge auflösen.
$ingredientLines = $this->productionService->expandBatchLines(
$payload['ingredient_lines'],
$payload['location_id']
);
$production = $this->productionService->store(
[
'product_id' => $payload['product_id'],
'location_id' => $payload['location_id'],
'produced_at' => $payload['produced_at'],
'quantity' => $payload['quantity'],
'notes' => $payload['notes'],
],
$ingredientLines,
(int) $request->user()->id,
$payload['outputs']
);
} catch (ValidationException $e) {
return redirect()->back()->withInput()->withErrors($e->errors());
}
if ($production->mhd_warning) {
\Session::flash('alert-warning', __('Hinweis: Mindestens eine Rohstoff-Charge hat ein kürzeres MHD als das geplante Produkt-MHD.'));
}
\Session::flash('alert-save', '1');
return redirect()->route('admin.inventory.productions.show', $production);
}
public function show(Production $production): View
{
$production->load([
'product',
'location',
'producedByUser.account',
'productionIngredients.ingredient',
'productionIngredients.stockEntry',
'productionPackagings.packagingItem.packagingMaterial',
'outputs.product',
]);
return view('admin.inventory.productions.show', [
'model' => $production,
]);
}
public function edit(Production $production): View
{
$production->load([
'product',
'location',
'productionIngredients.ingredient',
'productionIngredients.stockEntry',
'outputs',
]);
$defaultLocationId = $production->location_id;
return view('admin.inventory.productions.edit', [
'model' => $production,
'products' => Product::query()
->where(fn ($q) => $q->where('active', 1)->where('is_set', false))
->orWhere('id', $production->product_id)
->orderBy('name')->get(['id', 'name', 'batch_prefix']),
'locations' => Location::query()->where('active', true)->orderBy('name')->get(),
'defaultLocationId' => $defaultLocationId,
]);
}
public function update(StoreProductionRequest $request, Production $production): RedirectResponse
{
$payload = $request->validatedPayload();
try {
// AP-27: Beim Bearbeiten die eigene Produktion aus der Rest-Berechnung ausnehmen.
$ingredientLines = $this->productionService->expandBatchLines(
$payload['ingredient_lines'],
$payload['location_id'],
$production->id
);
$production = $this->productionService->updateProduction(
$production,
[
'product_id' => $payload['product_id'],
'location_id' => $payload['location_id'],
'produced_at' => $payload['produced_at'],
'quantity' => $payload['quantity'],
'notes' => $payload['notes'],
],
$ingredientLines,
(int) $request->user()->id,
$payload['outputs']
);
} catch (ValidationException $e) {
return redirect()->back()->withInput()->withErrors($e->errors());
}
if ($production->mhd_warning) {
\Session::flash('alert-warning', __('Hinweis: Mindestens eine Rohstoff-Charge hat ein kürzeres MHD als das geplante Produkt-MHD.'));
}
\Session::flash('alert-save', '1');
return redirect()->route('admin.inventory.productions.show', $production);
}
public function copy(Production $production): View
{
$production->load([
'product',
'productionIngredients.ingredient',
'productionIngredients.stockEntry',
'outputs',
]);
$defaultLocationId = $production->location_id;
return view('admin.inventory.productions.create', [
'model' => $production,
'products' => Product::query()->where('active', 1)->where('is_set', false)->orderBy('name')->get(['id', 'name', 'batch_prefix']),
'locations' => Location::query()->where('active', true)->orderBy('name')->get(),
'defaultLocationId' => $defaultLocationId,
]);
}
/**
* Anforderung 15.07.2026: Produktionen löschbar (Soft-Delete).
* Der gebuchte Produktbestand wird per Gegenbuchung zurückgeführt;
* der Rohstoffverbrauch der Produktion zählt danach nicht mehr
* (Bestandsqueries schließen gelöschte Produktionen aus).
*/
public function destroy(Production $production, ProductStockService $productStockService): RedirectResponse
{
if (! auth()->user()->isAdmin()) {
return redirect()->route('home');
}
\DB::transaction(function () use ($production, $productStockService) {
$productStockService->reverseProductionStock($production, (int) auth()->id());
$production->delete();
});
\Session::flash('alert-success', __('Produktion gelöscht — Produktbestand zurückgebucht, Rohstoffverbrauch freigegeben.'));
return redirect()->route('admin.inventory.productions.index');
}
public function recipeJson(Request $request, Product $product): JsonResponse
{
$locationId = (int) $request->query('location_id', 0);
$quantity = (int) $request->query('quantity', 1);
$excludeProductionId = (int) $request->query('exclude_production', 0) ?: null;
if ($locationId < 1) {
return response()->json(['message' => __('location_id erforderlich')], 422);
}
return response()->json(
$this->productionService->buildRecipePayload($product, $locationId, max(1, $quantity), $excludeProductionId)
);
}
}