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>
374 lines
16 KiB
PHP
374 lines
16 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\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)
|
||
);
|
||
}
|
||
}
|