gruene-seele/app/Http/Controllers/Admin/Inventory/ProductionController.php
Kevin Adametz abd202a02e Warenwirtschaft: AP-43 — Produktion zwischenspeichern (Entwurf) + „abschließen"
Der Speicher-Button heißt jetzt „Produktion abschließen"; daneben gibt es
„Zwischenspeichern (Entwurf)". Ein Entwurf sichert den Stand ohne Pflichtfeld-
und ohne Bestandsprüfung und bucht KEINEN Bestand (Roh-/Produkt-/Verpackung).
Erst „abschließen" prüft vollständig und bucht.

- Migration: productions.status (default 'completed'; Bestand ist bereits gebucht).
- Production-Model: STATUS_DRAFT/COMPLETED, isDraft(), scopeBooked().
- InventoryService: Entwürfe fallen aus allen Verbrauchs-/Bestandsjoins heraus
  (status != draft an den 4 productions-Joins).
- ProductionService store()/updateProduction() bekommen $draft: Entwurf
  überspringt Rezeptur-/Mengen-/Bestandsprüfung + Buchungen, sichert nur
  vollständige Chargen-Zeilen (filterCompleteLines) und die angegebenen Outputs.
  Ein abgeschlossene Produktion kann NICHT zurück in den Entwurf (sonst
  verwaisen die Buchungen) — Guard wirft ValidationException.
- StoreProductionRequest: save_mode=draft => nur Kopfdaten Pflicht, Chargen/
  Ergebnis optional; validatedPayload filtert unvollständige Zeilen/Outputs.
- Controller: Entwurf löst Chargen tolerant auf (nicht auflösbare Chargen werden
  übersprungen, Entwurf ist immer speicherbar) und leitet zur Bearbeitung zurück.
- Views: create/edit „abschließen" + „Zwischenspeichern"-Button (nur solange
  Entwurf); Entwurf-Badge in Liste + Bearbeiten-Kopf.

Tests: 5 neue (kein Produktbestand, kein Rohstoffverbrauch, Abschluss bucht,
Rück-Entwurf verboten, HTTP-Entwurf ohne Ergebnis ok / Abschluss erzwingt
Ergebnis). Migration auf Dev-/Live-DB. Suite 336 grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 09:59:42 +00:00

265 lines
10 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|RedirectResponse
{
// Audit M-2: Produktionen buchen Bestand — nur admin >= 7.
if (! auth()->user()->isAdmin()) {
return redirect()->route('home');
}
$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)->where('production_relevant', true)->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();
$isDraft = (bool) $payload['is_draft'];
try {
// AP-27: Chargen-Zeilen (batch_key) FEFO auf die Einzel-Wareneingänge der Charge auflösen.
$ingredientLines = $this->expandLines($payload['ingredient_lines'], $payload['location_id'], null, $isDraft);
$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'],
$isDraft
);
} catch (ValidationException $e) {
return redirect()->back()->withInput()->withErrors($e->errors());
}
return $this->afterSave($production, $isDraft);
}
/**
* AP-27/AP-43: Chargen-Zeilen auf Einzel-Wareneingänge auflösen. Im Entwurf
* darf das Auflösen scheitern (Charge nicht verfügbar) — dann bleiben nur die
* bereits konkret gewählten Wareneingänge erhalten, der Entwurf ist immer speicherbar.
*
* @param array<int, array<string, mixed>> $lines
* @return array<int, array<string, mixed>>
*/
private function expandLines(array $lines, int $locationId, ?int $excludeProductionId, bool $isDraft): array
{
if (! $isDraft) {
return $this->productionService->expandBatchLines($lines, $locationId, $excludeProductionId);
}
try {
return $this->productionService->expandBatchLines($lines, $locationId, $excludeProductionId);
} catch (ValidationException) {
return array_values(array_filter($lines, fn ($l) => (int) ($l['stock_entry_id'] ?? 0) > 0));
}
}
/**
* AP-43: Entwurf zurück in die Bearbeitung (Weiterarbeiten), abgeschlossene
* Produktion in die Detailansicht.
*/
private function afterSave(Production $production, bool $isDraft): RedirectResponse
{
if ($isDraft) {
\Session::flash('alert-save', '1');
return redirect()->route('admin.inventory.productions.edit', $production)
->with('alert-info', __('Entwurf zwischengespeichert. Die Produktion ist noch nicht gebucht.'));
}
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|RedirectResponse
{
if (! auth()->user()->isAdmin()) {
return redirect()->route('home');
}
$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)->where('production_relevant', true))
->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();
$isDraft = (bool) $payload['is_draft'];
try {
// AP-27: Beim Bearbeiten die eigene Produktion aus der Rest-Berechnung ausnehmen.
$ingredientLines = $this->expandLines($payload['ingredient_lines'], $payload['location_id'], $production->id, $isDraft);
$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'],
$isDraft
);
} catch (ValidationException $e) {
return redirect()->back()->withInput()->withErrors($e->errors());
}
return $this->afterSave($production, $isDraft);
}
public function copy(Production $production): View|RedirectResponse
{
if (! auth()->user()->isAdmin()) {
return redirect()->route('home');
}
$production->load([
'product',
'productionIngredients.ingredient',
'productionIngredients.stockEntry',
'outputs',
]);
// Audit N-8: Chargen-Nummern nicht in die Kopie übernehmen — das neue
// Produktionsdatum erzeugt sonst Duplikat-Chargen mit falschem Datum;
// leer ⇒ JS-Vorschlag (Präfix + ttmmjj) greift.
$production->outputs->each(fn ($output) => $output->setAttribute('batch_number', null));
$defaultLocationId = $production->location_id;
return view('admin.inventory.productions.create', [
'model' => $production,
'products' => Product::query()->where('active', 1)->where('is_set', false)->where('production_relevant', true)->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) {
// Audit M-9: Zeile sperren, damit Löschen nicht mit parallelem Update kollidiert.
Production::query()->lockForUpdate()->findOrFail($production->id);
$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)
);
}
}