Entwürfe speichern batch_key roh (stock_entry_id nullable), keine Auflösung und keine Filterung unfertiger Zeilen mehr beim Zwischenspeichern. Eine nicht mehr verfügbare Charge wird im Formular markiert statt alle Zeilen zu verwerfen. Ergebniszeilen dürfen im Entwurf ohne Stückzahl bleiben. Auflösung, Prüfung und Buchung erst beim Abschluss. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
292 lines
11 KiB
PHP
292 lines
11 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\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,
|
|
]);
|
|
}
|
|
|
|
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,
|
|
]);
|
|
}
|
|
|
|
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,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 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()
|
|
->where(fn ($q) => $q->where('active', 1)->where('is_set', false))
|
|
->when($existingOutputIds !== [], fn ($q) => $q->orWhereIn('id', $existingOutputIds))
|
|
->orderBy('name')
|
|
->get($columns);
|
|
|
|
return ['products' => $products, 'outputProducts' => $outputProducts];
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
);
|
|
}
|
|
}
|