gruene-seele/app/Http/Controllers/Admin/Inventory/ProductionController.php
Kevin Adametz 3abb9e644e Warenwirtschaft: AP-52 — Verpackungsvorschau mit Bestand, Differenz und Lagerort (Kundennachtrag 09.09.2026)
Je Verpackungsartikel Bestand (Stück), Differenz zu Bedarf (rot/grün wie bei
Rohstoffen, AP-40) und Lagerplatz; Spalte einheitlich „Lagerort“ für Rohstoffe
(INCI-Tabelle, vorher „Ablage“) und Verpackung.

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

374 lines
16 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\InventoryService;
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::defaultId();
$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;
// N-04 (Nachprüfung 08.09.2026): Bei gebuchter Produktion zeigt die Vorschau den
// gespeicherten Verpackungs-Snapshot, solange die Ergebnissummen unverändert sind
// (dieselbe Regel wie ProductionService::updateProduction, F-05).
$production->loadMissing('productionPackagings.packagingItem');
$packagingSnapshot = $production->isDraft() ? [] : $production->productionPackagings
->map(fn ($row) => ['id' => (int) $row->packaging_item_id, 'name' => $row->packagingItem?->name ?? ('#'.$row->packaging_item_id), 'pieces' => (int) $row->quantity_used])
->values()->all();
$initialOutputTotals = [];
foreach ($production->outputs as $output) {
$initialOutputTotals[(int) $output->product_id] = ($initialOutputTotals[(int) $output->product_id] ?? 0) + (int) $output->quantity;
}
return view('admin.inventory.productions.edit', [
'model' => $production,
'packagingSnapshot' => $packagingSnapshot,
// R-02: gebucht = historischer Snapshot gilt (auch wenn er leer ist: „kein Verbrauch“).
'packagingSnapshotApplies' => ! $production->isDraft(),
'initialOutputTotals' => $initialOutputTotals,
...$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.storageRoom', 'packagings.storageShelf', 'packagings.storageSlot')
->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();
// AP-52 (Kundennachtrag 09.09.2026): Bestand, Differenz und Lagerort je
// Verpackungsartikel in der Vorschau — wie bei den Rohstoffen in der INCI-Tabelle.
$packagingItems = $outputProducts->flatMap->packagings->unique('id');
if ($production !== null) {
$production->loadMissing('productionPackagings.packagingItem.storageRoom', 'productionPackagings.packagingItem.storageShelf', 'productionPackagings.packagingItem.storageSlot');
$packagingItems = $packagingItems->merge($production->productionPackagings->map->packagingItem->filter())->unique('id');
}
$stockByItem = $packagingItems->isEmpty() ? [] : app(InventoryService::class)->remainingByPackagingItem($packagingItems->pluck('id')->map(fn ($id) => (int) $id)->all());
$packagingInfo = $packagingItems->mapWithKeys(fn ($pk) => [(int) $pk->id => [
'name' => $pk->name,
'stock' => round((float) ($stockByItem[(int) $pk->id] ?? 0.0), 0),
'storage_label' => $pk->storageLabel(),
]])->all();
return ['products' => $products, 'outputProducts' => $outputProducts, 'packagingBom' => $packagingBom, 'packagingInfo' => $packagingInfo];
}
/**
* 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)
);
}
}