gruene-seele/app/Http/Controllers/Admin/Inventory/ProductionController.php
Kevin Adametz fd1572df35 Warenwirtschaft: F-07 — Verpackungsvorschau aus dem Produktionsergebnis
Stückliste je Ergebnisprodukt wird ins Formular gegeben; die Vorschau rechnet
Outputs × Stückliste (wie die Buchung) und reagiert auf Hinzufügen/Entfernen,
Produkt- und Mengenwechsel.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 10:59:48 +00:00

344 lines
14 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\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\Database\Eloquent\Collection;
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', [
...$this->productCatalogs(null),
'locations' => Location::query()->where('active', true)->orderBy('name')->get(),
'defaultLocationId' => $defaultLocationId,
'defaultProductId' => $defaultProductId,
'model' => null,
'existingLines' => $this->existingLines(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: Chargen-Zeilen auf Einzel-Wareneingänge auflösen (nur beim Abschluss).
* Prüfung 08.09.2026 F-03: Im Entwurf wird nicht aufgelöst — die Zeilen werden
* roh (batch_key) gesichert, damit eine nicht mehr verfügbare Charge nicht den
* gesamten Arbeitsstand verwirft. Aufgelöst und geprüft wird erst beim Abschluss.
*
* @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 $lines;
}
return $this->productionService->expandBatchLines($lines, $locationId, $excludeProductionId);
}
/**
* 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,
...$this->productCatalogs($production),
'locations' => Location::query()->where('active', true)->orderBy('name')->get(),
'defaultLocationId' => $defaultLocationId,
'existingLines' => $this->existingLines($production),
]);
}
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,
...$this->productCatalogs($production),
'locations' => Location::query()->where('active', true)->orderBy('name')->get(),
'defaultLocationId' => $defaultLocationId,
'existingLines' => $this->existingLines($production),
]);
}
/**
* Vorbelegung der Chargen-Zeilen im Formular, Schlüssel „Rohstoff|Phase“ (F-04).
* Prüfung 08.09.2026 F-06: Nach einem Validierungsfehler haben die zuletzt
* eingegebenen Zeilen (old()) Vorrang vor dem gespeicherten Stand — wie bei
* Kopfdaten und Ergebniszeilen. Sonst AP-27: Zeilen je Charge (batch_key)
* zusammenfassen, mehrere Einkäufe derselben Charge = eine Formularzeile.
*
* @return array<string, array<int, array{batch_key: string, quantity_used: float|string}>>
*/
private function existingLines(?Production $production): array
{
$old = old('ingredient_lines');
if (is_array($old) && $old !== []) {
return collect($old)
->filter(fn ($l) => is_array($l)
&& (int) ($l['ingredient_id'] ?? 0) > 0
&& (trim((string) ($l['batch_key'] ?? '')) !== '' || trim((string) ($l['quantity_used'] ?? '')) !== ''))
->groupBy(fn ($l) => (int) $l['ingredient_id'].'|'.(! empty($l['recipe_phase_id']) ? (int) $l['recipe_phase_id'] : ''))
->map(fn ($group) => $group->map(fn ($l) => [
'batch_key' => trim((string) ($l['batch_key'] ?? '')),
'quantity_used' => (string) ($l['quantity_used'] ?? ''),
])->values()->all())
->all();
}
if ($production === null) {
return [];
}
return $production->productionIngredients
->groupBy(fn ($l) => $l->formGroupKey())
->map(fn ($lines) => $lines
->groupBy(fn ($l) => $l->formBatchKey())
->map(fn ($group, $key) => ['batch_key' => (string) $key, 'quantity_used' => (float) $group->sum('quantity_used')])
->values()->all())
->all();
}
/**
* Prüfung 08.09.2026 F-02: Zwei getrennte Kataloge.
* - `products`: Planungsprodukt oben — nur produktionsrelevante Produkte (AP-47),
* plus das bereits geplante Produkt einer bestehenden Produktion.
* - `outputProducts`: Ergebniszeilen — alle aktiven Einzelprodukte, unabhängig von
* der Produktionsrelevanz (z. B. 5-ml-Variante aus der 50-ml-Masse), plus alle
* bereits gespeicherten Ergebnisprodukte, damit sie beim Bearbeiten/Kopieren
* nicht still verloren gehen.
*
* @return array{products: Collection<int, Product>, outputProducts: Collection<int, Product>}
*/
private function productCatalogs(?Production $production): array
{
$columns = ['id', 'name', 'batch_prefix'];
$products = Product::query()
->where(fn ($q) => $q->where('active', 1)->where('is_set', false)->where('production_relevant', true))
->when($production !== null, fn ($q) => $q->orWhere('id', $production->product_id))
->orderBy('name')
->get($columns);
$existingOutputIds = $production?->outputs?->pluck('product_id')->map(fn ($id) => (int) $id)->all() ?? [];
$outputProducts = Product::query()
->with('packagings:id,name')
->where(fn ($q) => $q->where('active', 1)->where('is_set', false))
->when($existingOutputIds !== [], fn ($q) => $q->orWhereIn('id', $existingOutputIds))
->orderBy('name')
->get($columns);
// F-07: Verpackungs-Stückliste je Ergebnisprodukt für die Vorschau im Browser —
// dieselbe Basis wie die Buchung (syncPackagingSnapshot: Outputs × Stückliste).
$packagingBom = $outputProducts
->mapWithKeys(fn (Product $p) => [(int) $p->id => $p->packagings->map(fn ($pk) => [
'id' => (int) $pk->id,
'name' => $pk->name,
'per_unit' => (float) ($pk->pivot->quantity ?? 1),
])->values()->all()])
->all();
return ['products' => $products, 'outputProducts' => $outputProducts, 'packagingBom' => $packagingBom];
}
/**
* 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)
);
}
}