Warenwirtschaft: AP-43 — Produktion zwischenspeichern (Entwurf) + „abschließen"
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>
This commit is contained in:
parent
3d19f743b1
commit
abd202a02e
10 changed files with 445 additions and 44 deletions
|
|
@ -57,13 +57,11 @@ class ProductionController extends Controller
|
|||
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->productionService->expandBatchLines(
|
||||
$payload['ingredient_lines'],
|
||||
$payload['location_id']
|
||||
);
|
||||
$ingredientLines = $this->expandLines($payload['ingredient_lines'], $payload['location_id'], null, $isDraft);
|
||||
|
||||
$production = $this->productionService->store(
|
||||
[
|
||||
|
|
@ -75,12 +73,50 @@ class ProductionController extends Controller
|
|||
],
|
||||
$ingredientLines,
|
||||
(int) $request->user()->id,
|
||||
$payload['outputs']
|
||||
$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.'));
|
||||
}
|
||||
|
|
@ -135,14 +171,11 @@ class ProductionController extends Controller
|
|||
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->productionService->expandBatchLines(
|
||||
$payload['ingredient_lines'],
|
||||
$payload['location_id'],
|
||||
$production->id
|
||||
);
|
||||
$ingredientLines = $this->expandLines($payload['ingredient_lines'], $payload['location_id'], $production->id, $isDraft);
|
||||
|
||||
$production = $this->productionService->updateProduction(
|
||||
$production,
|
||||
|
|
@ -155,18 +188,14 @@ class ProductionController extends Controller
|
|||
],
|
||||
$ingredientLines,
|
||||
(int) $request->user()->id,
|
||||
$payload['outputs']
|
||||
$payload['outputs'],
|
||||
$isDraft
|
||||
);
|
||||
} catch (ValidationException $e) {
|
||||
return redirect()->back()->withInput()->withErrors($e->errors());
|
||||
}
|
||||
|
||||
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);
|
||||
return $this->afterSave($production, $isDraft);
|
||||
}
|
||||
|
||||
public function copy(Production $production): View|RedirectResponse
|
||||
|
|
|
|||
|
|
@ -14,11 +14,41 @@ class StoreProductionRequest extends FormRequest
|
|||
return (bool) $this->user()?->isAdmin();
|
||||
}
|
||||
|
||||
/**
|
||||
* AP-43: „Zwischenspeichern" schickt save_mode=draft und speichert einen
|
||||
* Entwurf ohne Pflichtfeld-Validierung (Chargen/Ergebnis optional).
|
||||
*/
|
||||
public function isDraft(): bool
|
||||
{
|
||||
return $this->input('save_mode') === 'draft';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, mixed|string>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
// AP-43: Entwurf — nur die Kopfdaten (DB-Pflichtspalten) prüfen; Rezeptur-
|
||||
// Zeilen und Produktionsergebnis dürfen unvollständig bleiben.
|
||||
if ($this->isDraft()) {
|
||||
return [
|
||||
'product_id' => ['required', 'integer', 'exists:products,id'],
|
||||
'location_id' => ['required', 'integer', 'exists:locations,id'],
|
||||
'produced_at' => ['required', 'date'],
|
||||
'quantity' => ['required', 'integer', 'min:1'],
|
||||
'notes' => ['nullable', 'string', 'max:2000'],
|
||||
'ingredient_lines' => ['nullable', 'array'],
|
||||
'ingredient_lines.*.ingredient_id' => ['nullable', 'integer', 'exists:ingredients,id'],
|
||||
'ingredient_lines.*.batch_key' => ['nullable', 'string', 'max:120'],
|
||||
'ingredient_lines.*.stock_entry_id' => ['nullable', 'integer', 'exists:stock_entries,id'],
|
||||
'ingredient_lines.*.quantity_used' => ['nullable', 'string'],
|
||||
'outputs' => ['nullable', 'array'],
|
||||
'outputs.*.product_id' => ['nullable', 'integer', 'exists:products,id'],
|
||||
'outputs.*.quantity' => ['nullable', 'integer', 'min:1'],
|
||||
'outputs.*.batch_number' => ['nullable', 'string', 'max:100'],
|
||||
];
|
||||
}
|
||||
|
||||
$recipeRequired = $this->recipeRequired();
|
||||
|
||||
return [
|
||||
|
|
@ -62,7 +92,22 @@ class StoreProductionRequest extends FormRequest
|
|||
{
|
||||
$data = $this->validated();
|
||||
|
||||
// AP-43: Im Entwurf dürfen Zeilen unvollständig sein — nur solche mit
|
||||
// Rohstoff + (Charge oder Wareneingang) bzw. Ergebnis-Produkt übernehmen.
|
||||
$ingredientLines = array_values(array_filter($data['ingredient_lines'] ?? [], function ($line): bool {
|
||||
return is_array($line)
|
||||
&& (int) ($line['ingredient_id'] ?? 0) > 0
|
||||
&& (! empty($line['batch_key']) || (int) ($line['stock_entry_id'] ?? 0) > 0);
|
||||
}));
|
||||
|
||||
$outputs = array_values(array_filter($data['outputs'] ?? [], function ($output): bool {
|
||||
return is_array($output)
|
||||
&& (int) ($output['product_id'] ?? 0) > 0
|
||||
&& (int) ($output['quantity'] ?? 0) > 0;
|
||||
}));
|
||||
|
||||
return [
|
||||
'is_draft' => $this->isDraft(),
|
||||
'product_id' => (int) $data['product_id'],
|
||||
'location_id' => (int) $data['location_id'],
|
||||
'produced_at' => $data['produced_at'],
|
||||
|
|
@ -71,7 +116,7 @@ class StoreProductionRequest extends FormRequest
|
|||
'ingredient_lines' => array_map(function (array $line): array {
|
||||
$out = [
|
||||
'ingredient_id' => (int) $line['ingredient_id'],
|
||||
'quantity_used' => $line['quantity_used'],
|
||||
'quantity_used' => $line['quantity_used'] ?? null,
|
||||
];
|
||||
if (! empty($line['batch_key'])) {
|
||||
$out['batch_key'] = (string) $line['batch_key'];
|
||||
|
|
@ -80,14 +125,14 @@ class StoreProductionRequest extends FormRequest
|
|||
}
|
||||
|
||||
return $out;
|
||||
}, $data['ingredient_lines'] ?? []),
|
||||
}, $ingredientLines),
|
||||
'outputs' => array_map(function (array $output): array {
|
||||
return [
|
||||
'product_id' => (int) $output['product_id'],
|
||||
'quantity' => (int) $output['quantity'],
|
||||
'batch_number' => $output['batch_number'] ?? null,
|
||||
];
|
||||
}, $data['outputs'] ?? []),
|
||||
}, $outputs),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Models;
|
||||
|
||||
use App\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
|
@ -12,6 +13,12 @@ class Production extends Model
|
|||
{
|
||||
use SoftDeletes;
|
||||
|
||||
/** AP-43: Entwurf — gespeichert, aber noch nicht gebucht. */
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
|
||||
/** AP-43: Abgeschlossen — Bestand gebucht (Alt-Verhalten, Default). */
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
|
||||
protected $fillable = [
|
||||
'product_id',
|
||||
'location_id',
|
||||
|
|
@ -20,8 +27,29 @@ class Production extends Model
|
|||
'quantity',
|
||||
'notes',
|
||||
'mhd_warning',
|
||||
'status',
|
||||
];
|
||||
|
||||
/**
|
||||
* AP-43: true, solange die Produktion ein Entwurf ist (kein Bestand gebucht).
|
||||
*/
|
||||
public function isDraft(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_DRAFT;
|
||||
}
|
||||
|
||||
/**
|
||||
* AP-43: Nur gebuchte (abgeschlossene) Produktionen — Entwürfe zählen nicht
|
||||
* in Bestands-/Verbrauchsberechnungen.
|
||||
*
|
||||
* @param Builder<Production> $query
|
||||
* @return Builder<Production>
|
||||
*/
|
||||
public function scopeBooked($query)
|
||||
{
|
||||
return $query->where('status', '!=', self::STATUS_DRAFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Services;
|
||||
|
||||
use App\Models\Ingredient;
|
||||
use App\Models\Production;
|
||||
use App\Models\ProductionIngredient;
|
||||
use App\Models\ProductionPackaging;
|
||||
use App\Models\StockDisposal;
|
||||
|
|
@ -47,6 +48,8 @@ class InventoryService
|
|||
$consumed = ProductionIngredient::query()
|
||||
->join('productions', 'productions.id', '=', 'production_ingredients.production_id')
|
||||
->whereNull('productions.deleted_at')
|
||||
// AP-43: Entwürfe (nicht gebucht) zählen nicht in den Bestand.
|
||||
->where('productions.status', '!=', Production::STATUS_DRAFT)
|
||||
->when($ingredientIds !== null, fn ($q) => $q->whereIn('production_ingredients.ingredient_id', $ingredientIds))
|
||||
->selectRaw('production_ingredients.ingredient_id as ingredient_id, SUM(production_ingredients.quantity_used) as qty')
|
||||
->groupBy('production_ingredients.ingredient_id')
|
||||
|
|
@ -114,6 +117,8 @@ class InventoryService
|
|||
->join('stock_entries', 'stock_entries.id', '=', 'production_ingredients.stock_entry_id')
|
||||
->join('productions', 'productions.id', '=', 'production_ingredients.production_id')
|
||||
->whereNull('productions.deleted_at')
|
||||
// AP-43: Entwürfe (nicht gebucht) zählen nicht in den Bestand.
|
||||
->where('productions.status', '!=', Production::STATUS_DRAFT)
|
||||
->where('production_ingredients.ingredient_id', $ingredientId)
|
||||
->selectRaw('stock_entries.location_id as location_id, SUM(production_ingredients.quantity_used) as qty')
|
||||
->groupBy('stock_entries.location_id')
|
||||
|
|
@ -163,6 +168,8 @@ class InventoryService
|
|||
$consumed = ProductionPackaging::query()
|
||||
->join('productions', 'productions.id', '=', 'production_packagings.production_id')
|
||||
->whereNull('productions.deleted_at')
|
||||
// AP-43: Entwürfe (nicht gebucht) zählen nicht in den Bestand.
|
||||
->where('productions.status', '!=', Production::STATUS_DRAFT)
|
||||
->when($packagingItemIds !== null, fn ($q) => $q->whereIn('production_packagings.packaging_item_id', $packagingItemIds))
|
||||
->selectRaw('production_packagings.packaging_item_id as packaging_item_id, SUM(production_packagings.quantity_used) as qty')
|
||||
->groupBy('production_packagings.packaging_item_id')
|
||||
|
|
@ -249,6 +256,8 @@ class InventoryService
|
|||
$rows = ProductionIngredient::query()
|
||||
->join('productions', 'productions.id', '=', 'production_ingredients.production_id')
|
||||
->whereNull('productions.deleted_at')
|
||||
// AP-43: Entwürfe (nicht gebucht) zählen nicht in den Bestand.
|
||||
->where('productions.status', '!=', Production::STATUS_DRAFT)
|
||||
->where('productions.produced_at', '>=', $since)
|
||||
->when($ingredientIds !== null, fn ($q) => $q->whereIn('production_ingredients.ingredient_id', $ingredientIds))
|
||||
->selectRaw('production_ingredients.ingredient_id as ingredient_id, SUM(production_ingredients.quantity_used) as qty')
|
||||
|
|
|
|||
|
|
@ -23,9 +23,9 @@ class ProductionService
|
|||
* @param array<int, array{ingredient_id: int, stock_entry_id: int, quantity_used: float|int|string}> $ingredientLines
|
||||
* @param array<int, array{product_id: int, quantity: int, batch_number?: ?string}> $outputs AP-28: Produktionsergebnis; leer ⇒ Fallback auf geplantes Produkt + Stückzahl
|
||||
*/
|
||||
public function store(array $data, array $ingredientLines, int $userId, array $outputs = []): Production
|
||||
public function store(array $data, array $ingredientLines, int $userId, array $outputs = [], bool $draft = false): Production
|
||||
{
|
||||
return DB::transaction(function () use ($data, $ingredientLines, $userId, $outputs) {
|
||||
return DB::transaction(function () use ($data, $ingredientLines, $userId, $outputs, $draft) {
|
||||
$product = Product::query()
|
||||
->with(['manufacturer_ingredients', 'packagings'])
|
||||
->findOrFail($data['product_id']);
|
||||
|
|
@ -38,7 +38,11 @@ class ProductionService
|
|||
|
||||
$this->assertNotASet($product);
|
||||
|
||||
if ($product->no_recipe_required) {
|
||||
if ($draft) {
|
||||
// AP-43: Entwurf — keine Rezeptur-/Mengen-/Bestandsprüfung. Es werden
|
||||
// nur bereits vollständig ausgefüllte Chargen-Zeilen gesichert.
|
||||
$ingredientLines = $this->filterCompleteLines($ingredientLines);
|
||||
} elseif ($product->no_recipe_required) {
|
||||
$ingredientLines = [];
|
||||
} else {
|
||||
$this->assertManufacturerRecipe($product);
|
||||
|
|
@ -52,7 +56,7 @@ class ProductionService
|
|||
}
|
||||
}
|
||||
|
||||
$mhdWarning = $this->computeMhdWarning($product, $data['produced_at'], $ingredientLines);
|
||||
$mhdWarning = $draft ? false : $this->computeMhdWarning($product, $data['produced_at'], $ingredientLines);
|
||||
|
||||
$production = Production::query()->create([
|
||||
'product_id' => $product->id,
|
||||
|
|
@ -62,6 +66,7 @@ class ProductionService
|
|||
'quantity' => $producedQty,
|
||||
'notes' => $data['notes'] ?? null,
|
||||
'mhd_warning' => $mhdWarning,
|
||||
'status' => $draft ? Production::STATUS_DRAFT : Production::STATUS_COMPLETED,
|
||||
]);
|
||||
|
||||
foreach ($ingredientLines as $line) {
|
||||
|
|
@ -72,14 +77,21 @@ class ProductionService
|
|||
]);
|
||||
}
|
||||
|
||||
$this->persistOutputs($production, $product, $producedQty, $outputs);
|
||||
// AP-43: Für den Entwurf nur die tatsächlich angegebenen Ergebnis-Zeilen
|
||||
// sichern (kein Planungs-Fallback), sonst wie gehabt.
|
||||
if (! $draft || $outputs !== []) {
|
||||
$this->persistOutputs($production, $product, $producedQty, $outputs);
|
||||
}
|
||||
|
||||
// AP-45: Verpackungsverbrauch aus dem tatsächlichen Produktionsergebnis
|
||||
// buchen (je Ergebnisprodukt), daher nach persistOutputs.
|
||||
$this->syncPackagingSnapshot($production);
|
||||
// AP-43: Entwürfe buchen keinen Bestand (Roh-/Produkt-/Verpackung).
|
||||
if (! $draft) {
|
||||
// AP-45: Verpackungsverbrauch aus dem tatsächlichen Produktionsergebnis
|
||||
// buchen (je Ergebnisprodukt), daher nach persistOutputs.
|
||||
$this->syncPackagingSnapshot($production);
|
||||
|
||||
$production->setRelation('product', $product);
|
||||
$this->productStockService->recordProductionStock($production, $userId);
|
||||
$production->setRelation('product', $product);
|
||||
$this->productStockService->recordProductionStock($production, $userId);
|
||||
}
|
||||
|
||||
return $production->fresh(['product', 'location', 'productionIngredients', 'productionPackagings', 'outputs']);
|
||||
});
|
||||
|
|
@ -90,12 +102,20 @@ class ProductionService
|
|||
* @param array<int, array{ingredient_id: int, stock_entry_id: int, quantity_used: float|int|string}> $ingredientLines
|
||||
* @param array<int, array{product_id: int, quantity: int, batch_number?: ?string}> $outputs AP-28: Produktionsergebnis; leer ⇒ Fallback auf geplantes Produkt + Stückzahl
|
||||
*/
|
||||
public function updateProduction(Production $production, array $data, array $ingredientLines, int $userId, array $outputs = []): Production
|
||||
public function updateProduction(Production $production, array $data, array $ingredientLines, int $userId, array $outputs = [], bool $draft = false): Production
|
||||
{
|
||||
return DB::transaction(function () use ($production, $data, $ingredientLines, $userId, $outputs) {
|
||||
return DB::transaction(function () use ($production, $data, $ingredientLines, $userId, $outputs, $draft) {
|
||||
// Audit M-9: Produktionszeile sperren — parallele Updates derselben
|
||||
// Produktion lesen sonst denselben Buchungs-Snapshot und buchen doppelt.
|
||||
Production::query()->lockForUpdate()->findOrFail($production->id);
|
||||
$locked = Production::query()->lockForUpdate()->findOrFail($production->id);
|
||||
|
||||
// AP-43: Eine bereits abgeschlossene (gebuchte) Produktion darf nicht
|
||||
// zurück in den Entwurf — die Buchungen würden sonst verwaisen.
|
||||
if ($draft && ! $locked->isDraft()) {
|
||||
throw ValidationException::withMessages([
|
||||
'status' => __('Eine abgeschlossene Produktion kann nicht mehr als Entwurf gespeichert werden.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$product = Product::query()
|
||||
->with(['manufacturer_ingredients', 'packagings'])
|
||||
|
|
@ -109,7 +129,10 @@ class ProductionService
|
|||
|
||||
$this->assertNotASet($product);
|
||||
|
||||
if ($product->no_recipe_required) {
|
||||
if ($draft) {
|
||||
// AP-43: Entwurf — keine Rezeptur-/Mengen-/Bestandsprüfung.
|
||||
$ingredientLines = $this->filterCompleteLines($ingredientLines);
|
||||
} elseif ($product->no_recipe_required) {
|
||||
$ingredientLines = [];
|
||||
} else {
|
||||
$this->assertManufacturerRecipe($product);
|
||||
|
|
@ -123,7 +146,7 @@ class ProductionService
|
|||
}
|
||||
}
|
||||
|
||||
$mhdWarning = $this->computeMhdWarning($product, $data['produced_at'], $ingredientLines);
|
||||
$mhdWarning = $draft ? false : $this->computeMhdWarning($product, $data['produced_at'], $ingredientLines);
|
||||
|
||||
$production->update([
|
||||
'product_id' => $product->id,
|
||||
|
|
@ -132,6 +155,7 @@ class ProductionService
|
|||
'quantity' => $producedQty,
|
||||
'notes' => $data['notes'] ?? null,
|
||||
'mhd_warning' => $mhdWarning,
|
||||
'status' => $draft ? Production::STATUS_DRAFT : Production::STATUS_COMPLETED,
|
||||
]);
|
||||
|
||||
$production->productionIngredients()->delete();
|
||||
|
|
@ -144,14 +168,20 @@ class ProductionService
|
|||
}
|
||||
|
||||
$production->outputs()->delete();
|
||||
$this->persistOutputs($production, $product, $producedQty, $outputs);
|
||||
if (! $draft || $outputs !== []) {
|
||||
$this->persistOutputs($production, $product, $producedQty, $outputs);
|
||||
}
|
||||
|
||||
// AP-45: Verpackung folgt dem Produktionsergebnis, daher nach den Outputs.
|
||||
// AP-43: Entwürfe buchen keinen Bestand. Ein zuvor gelöschter Verpackungs-
|
||||
// Snapshot bleibt für den Entwurf leer.
|
||||
$production->productionPackagings()->delete();
|
||||
$this->syncPackagingSnapshot($production);
|
||||
if (! $draft) {
|
||||
// AP-45: Verpackung folgt dem Produktionsergebnis, daher nach den Outputs.
|
||||
$this->syncPackagingSnapshot($production);
|
||||
|
||||
$production->setRelation('product', $product);
|
||||
$this->productStockService->recordProductionStock($production, $userId);
|
||||
$production->setRelation('product', $product);
|
||||
$this->productStockService->recordProductionStock($production, $userId);
|
||||
}
|
||||
|
||||
return $production->fresh(['product', 'location', 'productionIngredients', 'productionPackagings', 'outputs']);
|
||||
});
|
||||
|
|
@ -706,4 +736,20 @@ class ProductionService
|
|||
|
||||
return $n !== null && $n !== '' && is_numeric($n) ? (float) $n : 0.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* AP-43: Für den Entwurf nur vollständige Chargen-Zeilen sichern — eine Zeile
|
||||
* ist vollständig, wenn ein konkreter Wareneingang (stock_entry_id) gewählt ist
|
||||
* und eine Menge > 0 eingetragen wurde. Unfertige Zeilen werden verworfen.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $ingredientLines
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function filterCompleteLines(array $ingredientLines): array
|
||||
{
|
||||
return array_values(array_filter($ingredientLines, function (array $line): bool {
|
||||
return (int) ($line['stock_entry_id'] ?? 0) > 0
|
||||
&& $this->parseQuantity($line['quantity_used'] ?? null) > 0;
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* AP-43: Entwurfsstatus für Produktionen. „Zwischenspeichern" legt eine
|
||||
* Produktion als Entwurf ab (ohne Pflichtfeld-Validierung, ohne Bestandsbuchung);
|
||||
* erst „Produktion abschließen" bucht Roh-/Produkt-/Verpackungsbestand.
|
||||
* Bestehende Produktionen sind bereits gebucht ⇒ Default 'completed'.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('productions', function (Blueprint $table) {
|
||||
$table->string('status', 20)->default('completed')->after('mhd_warning');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('productions', function (Blueprint $table) {
|
||||
$table->dropColumn('status');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -9,7 +9,9 @@
|
|||
@csrf
|
||||
@include('admin.inventory.productions._form_fields', ['isEdit' => false])
|
||||
|
||||
<button type="submit" class="btn btn-primary" id="btn-submit-production">{{ __('Produktion speichern') }}</button>
|
||||
<button type="submit" name="save_mode" value="complete" class="btn btn-primary" id="btn-submit-production">{{ __('Produktion abschließen') }}</button>
|
||||
{{-- AP-43: Zwischenspeichern als Entwurf — ohne Pflichtfeld-/Bestandsprüfung, bucht keinen Bestand. --}}
|
||||
<button type="submit" name="save_mode" value="draft" class="btn btn-outline-primary" id="btn-save-draft" formnovalidate>{{ __('Zwischenspeichern (Entwurf)') }}</button>
|
||||
<a href="{{ route('admin.inventory.productions.index') }}" class="btn btn-outline-secondary">{{ __('Abbrechen') }}</a>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
@extends('layouts.layout-2')
|
||||
|
||||
@section('content')
|
||||
<h4 class="font-weight-bold py-2 mb-2">{{ __('Produktion bearbeiten') }} #{{ $model->id }}</h4>
|
||||
<h4 class="font-weight-bold py-2 mb-2">
|
||||
{{ __('Produktion bearbeiten') }} #{{ $model->id }}
|
||||
@if ($model->isDraft())
|
||||
<span class="badge badge-warning align-middle ml-1">{{ __('Entwurf') }}</span>
|
||||
@endif
|
||||
</h4>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
|
|
@ -10,7 +15,11 @@
|
|||
@method('PUT')
|
||||
@include('admin.inventory.productions._form_fields', ['isEdit' => true])
|
||||
|
||||
<button type="submit" class="btn btn-primary" id="btn-submit-production">{{ __('Produktion speichern') }}</button>
|
||||
<button type="submit" name="save_mode" value="complete" class="btn btn-primary" id="btn-submit-production">{{ __('Produktion abschließen') }}</button>
|
||||
{{-- AP-43: „Zwischenspeichern" nur solange die Produktion noch nicht gebucht ist (Entwurf). --}}
|
||||
@if ($model->isDraft())
|
||||
<button type="submit" name="save_mode" value="draft" class="btn btn-outline-primary" id="btn-save-draft" formnovalidate>{{ __('Zwischenspeichern (Entwurf)') }}</button>
|
||||
@endif
|
||||
<a href="{{ route('admin.inventory.productions.show', $model) }}" class="btn btn-outline-secondary">{{ __('Abbrechen') }}</a>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -54,7 +54,12 @@
|
|||
@endif
|
||||
</td>
|
||||
<td data-sort="{{ $row->produced_at?->timestamp ?? 0 }}">{{ $row->produced_at?->format('d.m.Y') }}</td>
|
||||
<td><span class="wawi-item-name">{{ $row->product?->name ?? '—' }}</span></td>
|
||||
<td>
|
||||
<span class="wawi-item-name">{{ $row->product?->name ?? '—' }}</span>
|
||||
@if($row->isDraft())
|
||||
<span class="badge badge-warning ml-1">{{ __('Entwurf') }}</span>
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
{{-- AP-28: reale Outputs statt Planungs-Stückzahl --}}
|
||||
@if($row->outputs->isNotEmpty())
|
||||
|
|
|
|||
200
tests/Feature/ProductionDraftTest.php
Normal file
200
tests/Feature/ProductionDraftTest.php
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Country;
|
||||
use App\Models\Ingredient;
|
||||
use App\Models\Location;
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductIngredient;
|
||||
use App\Models\Production;
|
||||
use App\Models\StockEntry;
|
||||
use App\Models\Supplier;
|
||||
use App\Services\InventoryService;
|
||||
use App\Services\ProductionService;
|
||||
use App\Services\ProductStockService;
|
||||
use App\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Passport\Passport;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
function draftCountry(): Country
|
||||
{
|
||||
return Country::query()->firstOrCreate(
|
||||
['code' => 'DE'],
|
||||
['phone' => '00', 'en' => 'Germany', 'de' => 'Deutschland', 'es' => 'Germany', 'fr' => 'Germany', 'it' => 'Germany', 'ru' => 'Germany', 'active' => true]
|
||||
);
|
||||
}
|
||||
|
||||
function draftUser(int $adminLevel = 7): User
|
||||
{
|
||||
draftCountry();
|
||||
$user = User::query()->create(['email' => uniqid('draft_', true).'@test.example', 'password' => bcrypt('password')]);
|
||||
$user->forceFill(['admin' => $adminLevel, 'confirmed' => true, 'active' => true, 'wizard' => 100, 'blocked' => false])->save();
|
||||
|
||||
return $user->fresh();
|
||||
}
|
||||
|
||||
function draftRecipeProduct(Ingredient $ing): Product
|
||||
{
|
||||
$product = Product::query()->create(['name' => 'Draft-Produkt', 'title' => 'Draft-Produkt', 'active' => true]);
|
||||
// Hersteller-Rezeptur: 10 g * 1.0 * 1 Stück = 10 g Soll
|
||||
ProductIngredient::query()->create([
|
||||
'product_id' => $product->id, 'ingredient_id' => $ing->id, 'pos' => 0,
|
||||
'gram' => 10, 'factor' => 1.0, 'recipe_type' => 'manufacturer',
|
||||
]);
|
||||
|
||||
return $product;
|
||||
}
|
||||
|
||||
function draftIngredient(): Ingredient
|
||||
{
|
||||
return Ingredient::query()->create([
|
||||
'name' => 'Draft-Roh', 'trans_name' => '', 'inci' => 'DR', 'trans_inci' => '',
|
||||
'effect' => '', 'trans_effect' => '', 'active' => true, 'pos' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
function draftStock(Ingredient $ing, Location $location, Supplier $supplier, User $user): StockEntry
|
||||
{
|
||||
return StockEntry::query()->create([
|
||||
'entry_type' => 'ingredient', 'ingredient_id' => $ing->id, 'packaging_item_id' => null,
|
||||
'supplier_id' => $supplier->id, 'location_id' => $location->id, 'unit' => 'gram',
|
||||
'ordered_by' => $user->id, 'ordered_at' => '2026-01-01', 'ordered_quantity' => 1000,
|
||||
'price_per_kg' => 1, 'status' => 'received', 'received_by' => $user->id,
|
||||
'received_at' => '2026-01-15', 'received_quantity' => 1000, 'batch_number' => 'DRAFT-B',
|
||||
'best_before' => '2030-12-31', 'quality_id' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
test('AP-43: entwurf bucht keinen produktbestand und bleibt status draft', function () {
|
||||
$user = draftUser();
|
||||
$location = Location::factory()->create();
|
||||
$product = Product::query()->create(['name' => 'Draft-Simpel', 'title' => 'Draft-Simpel', 'active' => true, 'no_recipe_required' => true]);
|
||||
|
||||
$production = app(ProductionService::class)->store(
|
||||
['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 20, 'notes' => 'WIP'],
|
||||
[],
|
||||
$user->id,
|
||||
[['product_id' => $product->id, 'quantity' => 20, 'batch_number' => null]],
|
||||
true // draft
|
||||
);
|
||||
|
||||
expect($production->isDraft())->toBeTrue()
|
||||
->and(app(ProductStockService::class)->currentStock($product->id))->toBe(0);
|
||||
});
|
||||
|
||||
test('AP-43: entwurf reduziert den rohstoffbestand nicht', function () {
|
||||
$user = draftUser();
|
||||
$location = Location::factory()->create();
|
||||
$supplier = Supplier::factory()->create();
|
||||
$ing = draftIngredient();
|
||||
$product = draftRecipeProduct($ing);
|
||||
$stock = draftStock($ing, $location, $supplier, $user);
|
||||
|
||||
$before = app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id] ?? 0.0;
|
||||
|
||||
app(ProductionService::class)->store(
|
||||
['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1, 'notes' => null],
|
||||
[['ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'quantity_used' => '10']],
|
||||
$user->id,
|
||||
[['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]],
|
||||
true // draft
|
||||
);
|
||||
|
||||
$after = app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id] ?? 0.0;
|
||||
|
||||
expect((float) $after)->toBe((float) $before); // Entwurf verbraucht nichts
|
||||
});
|
||||
|
||||
test('AP-43: entwurf abschließen bucht bestand und wird completed', function () {
|
||||
$user = draftUser();
|
||||
$location = Location::factory()->create();
|
||||
$supplier = Supplier::factory()->create();
|
||||
$ing = draftIngredient();
|
||||
$product = draftRecipeProduct($ing);
|
||||
$stock = draftStock($ing, $location, $supplier, $user);
|
||||
|
||||
$service = app(ProductionService::class);
|
||||
|
||||
$draft = $service->store(
|
||||
['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1, 'notes' => null],
|
||||
[['ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'quantity_used' => '10']],
|
||||
$user->id,
|
||||
[['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]],
|
||||
true
|
||||
);
|
||||
|
||||
expect(app(ProductStockService::class)->currentStock($product->id))->toBe(0);
|
||||
|
||||
$completed = $service->updateProduction(
|
||||
$draft->fresh(),
|
||||
['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1, 'notes' => null],
|
||||
[['ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'quantity_used' => '10']],
|
||||
$user->id,
|
||||
[['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]],
|
||||
false // abschließen
|
||||
);
|
||||
|
||||
expect($completed->isDraft())->toBeFalse()
|
||||
->and($completed->status)->toBe(Production::STATUS_COMPLETED)
|
||||
->and(app(ProductStockService::class)->currentStock($product->id))->toBe(1);
|
||||
|
||||
// Jetzt zählt der Rohstoffverbrauch: 1000 - 10 = 990
|
||||
$remaining = app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id] ?? 0.0;
|
||||
expect((float) $remaining)->toBe(990.0);
|
||||
});
|
||||
|
||||
test('AP-43: abgeschlossene produktion kann nicht zum entwurf zurück', function () {
|
||||
$user = draftUser();
|
||||
$location = Location::factory()->create();
|
||||
$product = Product::query()->create(['name' => 'Draft-Done', 'title' => 'Draft-Done', 'active' => true, 'no_recipe_required' => true]);
|
||||
|
||||
$completed = app(ProductionService::class)->store(
|
||||
['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 5, 'notes' => null],
|
||||
[],
|
||||
$user->id,
|
||||
[['product_id' => $product->id, 'quantity' => 5, 'batch_number' => null]],
|
||||
false
|
||||
);
|
||||
|
||||
expect(fn () => app(ProductionService::class)->updateProduction(
|
||||
$completed->fresh(),
|
||||
['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 5, 'notes' => null],
|
||||
[],
|
||||
$user->id,
|
||||
[['product_id' => $product->id, 'quantity' => 5, 'batch_number' => null]],
|
||||
true // Rück-Entwurf verboten
|
||||
))->toThrow(ValidationException::class);
|
||||
});
|
||||
|
||||
test('AP-43: HTTP zwischenspeichern ohne ergebnis speichert entwurf; abschließen erzwingt ergebnis', function () {
|
||||
$user = draftUser();
|
||||
$location = Location::factory()->create();
|
||||
$product = Product::query()->create(['name' => 'Draft-HTTP', 'title' => 'Draft-HTTP', 'active' => true, 'no_recipe_required' => true]);
|
||||
|
||||
Passport::actingAs($user);
|
||||
$this->actingAs($user, 'user');
|
||||
|
||||
// Entwurf: kein Produktionsergebnis nötig → wird gespeichert (Redirect zur Bearbeitung)
|
||||
$this->post(route('admin.inventory.productions.store'), [
|
||||
'save_mode' => 'draft',
|
||||
'product_id' => $product->id,
|
||||
'location_id' => $location->id,
|
||||
'produced_at' => '2026-08-01',
|
||||
'quantity' => 3,
|
||||
])->assertRedirect();
|
||||
|
||||
$draft = Production::query()->where('product_id', $product->id)->sole();
|
||||
expect($draft->isDraft())->toBeTrue();
|
||||
|
||||
// Abschließen ohne outputs → Validierungsfehler (outputs required)
|
||||
$this->from(route('admin.inventory.productions.create'))->post(route('admin.inventory.productions.store'), [
|
||||
'save_mode' => 'complete',
|
||||
'product_id' => $product->id,
|
||||
'location_id' => $location->id,
|
||||
'produced_at' => '2026-08-01',
|
||||
'quantity' => 3,
|
||||
])->assertSessionHasErrors('outputs');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue