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>
265 lines
10 KiB
PHP
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)
|
|
);
|
|
}
|
|
}
|