From 99a6c92e6c6fbc99c97d51cfb055dab1ae69d2a4 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Mon, 6 Jul 2026 14:02:54 +0000 Subject: [PATCH] Warenwirtschaft: AP-28 Neue Produktions-Maske + Produktionsergebnis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - production_outputs (Migration inkl. idempotentem Backfill): eine Produktion kann mehrere Output-Produkte erzeugen (z. B. 40x50 ml + 50x5 ml) - ProductStockService bucht je Output-Produkt (idempotenter Diff, append-only); die geplante Stückzahl bucht nichts mehr, entfernte Outputs werden rückgebucht - ProductionService store/update mit outputs[] (leer = Fallback auf Planung inkl. suggestedBatchNumber — Backfill-Semantik, Alt-Aufrufer stabil) - Maske: INCI-Liste (Alternativname/Qualität/Anteil/Gramm/Regal), Chargen- Erfassung nach Rezeptur-Phasen mit Notiz + Status-Ampel, Produktionsergebnis- Repeater mit Chargen-Nr.-Vorbelegung (Präfix + ttmmjj), Planungs-Hinweis - Show: Produktionsergebnis-Karte; Index: Ergebnis-Spalte - Hinweise-Doku + Plan V6.0 §6 fortgeschrieben (nächster Schritt: AP-13) Tests: ProductionOutputTest (8 neu); Suite 201 grün, 731 Assertionen Co-Authored-By: Claude Fable 5 --- .../Admin/Inventory/ProductionController.php | 15 +- .../Inventory/StoreProductionRequest.php | 12 + app/Models/Production.php | 11 + app/Models/ProductionOutput.php | 49 +++ app/Repositories/ProductionRepository.php | 2 +- app/Services/ProductStockService.php | 59 +++- app/Services/ProductionService.php | 87 ++++- ...153907_create_production_outputs_table.php | 48 +++ ...ntwicklungsplan-aktualisiert-06-07-2026.md | 8 +- resources/docs/hinweise.md | 22 +- .../productions/_form_fields.blade.php | 80 ++++- .../inventory/productions/_scripts.blade.php | 308 +++++++++++++++-- .../inventory/productions/index.blade.php | 11 +- .../inventory/productions/show.blade.php | 35 +- .../ProductionManufacturerRecipeTest.php | 8 + tests/Feature/ProductionOutputTest.php | 325 ++++++++++++++++++ tests/Feature/StockEntryChargeMergeTest.php | 3 + 17 files changed, 1009 insertions(+), 74 deletions(-) create mode 100644 app/Models/ProductionOutput.php create mode 100644 database/migrations/2026_07_06_153907_create_production_outputs_table.php create mode 100644 tests/Feature/ProductionOutputTest.php diff --git a/app/Http/Controllers/Admin/Inventory/ProductionController.php b/app/Http/Controllers/Admin/Inventory/ProductionController.php index 4431f9c..e120284 100644 --- a/app/Http/Controllers/Admin/Inventory/ProductionController.php +++ b/app/Http/Controllers/Admin/Inventory/ProductionController.php @@ -40,7 +40,7 @@ class ProductionController extends Controller $defaultProductId = (int) $request->query('product_id') ?: null; return view('admin.inventory.productions.create', [ - 'products' => Product::query()->where('active', 1)->where('is_set', false)->orderBy('name')->get(['id', 'name']), + 'products' => Product::query()->where('active', 1)->where('is_set', false)->orderBy('name')->get(['id', 'name', 'batch_prefix']), 'locations' => Location::query()->where('active', true)->orderBy('name')->get(), 'defaultLocationId' => $defaultLocationId, 'defaultProductId' => $defaultProductId, @@ -68,7 +68,8 @@ class ProductionController extends Controller 'notes' => $payload['notes'], ], $ingredientLines, - (int) $request->user()->id + (int) $request->user()->id, + $payload['outputs'] ); } catch (ValidationException $e) { return redirect()->back()->withInput()->withErrors($e->errors()); @@ -91,6 +92,7 @@ class ProductionController extends Controller 'productionIngredients.ingredient', 'productionIngredients.stockEntry', 'productionPackagings.packagingItem.packagingMaterial', + 'outputs.product', ]); return view('admin.inventory.productions.show', [ @@ -105,6 +107,7 @@ class ProductionController extends Controller 'location', 'productionIngredients.ingredient', 'productionIngredients.stockEntry', + 'outputs', ]); $defaultLocationId = $production->location_id; @@ -114,7 +117,7 @@ class ProductionController extends Controller 'products' => Product::query() ->where(fn ($q) => $q->where('active', 1)->where('is_set', false)) ->orWhere('id', $production->product_id) - ->orderBy('name')->get(['id', 'name']), + ->orderBy('name')->get(['id', 'name', 'batch_prefix']), 'locations' => Location::query()->where('active', true)->orderBy('name')->get(), 'defaultLocationId' => $defaultLocationId, ]); @@ -142,7 +145,8 @@ class ProductionController extends Controller 'notes' => $payload['notes'], ], $ingredientLines, - (int) $request->user()->id + (int) $request->user()->id, + $payload['outputs'] ); } catch (ValidationException $e) { return redirect()->back()->withInput()->withErrors($e->errors()); @@ -162,13 +166,14 @@ class ProductionController extends Controller 'product', 'productionIngredients.ingredient', 'productionIngredients.stockEntry', + 'outputs', ]); $defaultLocationId = $production->location_id; return view('admin.inventory.productions.create', [ 'model' => $production, - 'products' => Product::query()->where('active', 1)->where('is_set', false)->orderBy('name')->get(['id', 'name']), + 'products' => Product::query()->where('active', 1)->where('is_set', false)->orderBy('name')->get(['id', 'name', 'batch_prefix']), 'locations' => Location::query()->where('active', true)->orderBy('name')->get(), 'defaultLocationId' => $defaultLocationId, ]); diff --git a/app/Http/Requests/Inventory/StoreProductionRequest.php b/app/Http/Requests/Inventory/StoreProductionRequest.php index 1f39dc7..8653c97 100644 --- a/app/Http/Requests/Inventory/StoreProductionRequest.php +++ b/app/Http/Requests/Inventory/StoreProductionRequest.php @@ -31,6 +31,11 @@ class StoreProductionRequest extends FormRequest 'ingredient_lines.*.batch_key' => ['required_without:ingredient_lines.*.stock_entry_id', 'nullable', 'string', 'max:120'], 'ingredient_lines.*.stock_entry_id' => ['required_without:ingredient_lines.*.batch_key', 'nullable', 'integer', 'exists:stock_entries,id'], 'ingredient_lines.*.quantity_used' => ['required', 'string'], + // AP-28: Produktionsergebnis — bucht den Produktbestand; Planung oben ist unverbindlich. + 'outputs' => ['required', 'array', 'min:1'], + 'outputs.*.product_id' => ['required', 'integer', 'exists:products,id'], + 'outputs.*.quantity' => ['required', 'integer', 'min:1'], + 'outputs.*.batch_number' => ['nullable', 'string', 'max:100'], ]; } @@ -67,6 +72,13 @@ class StoreProductionRequest extends FormRequest return $out; }, $data['ingredient_lines'] ?? []), + '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'] ?? []), ]; } } diff --git a/app/Models/Production.php b/app/Models/Production.php index afd5061..35d9de6 100644 --- a/app/Models/Production.php +++ b/app/Models/Production.php @@ -69,4 +69,15 @@ class Production extends Model { return $this->hasMany(ProductionPackaging::class); } + + /** + * AP-28: Produktionsergebnis-Zeilen — buchen den Produktbestand; + * `quantity` an der Produktion ist nur noch die Planung. + * + * @return HasMany + */ + public function outputs(): HasMany + { + return $this->hasMany(ProductionOutput::class)->orderBy('pos')->orderBy('id'); + } } diff --git a/app/Models/ProductionOutput.php b/app/Models/ProductionOutput.php new file mode 100644 index 0000000..8a506a0 --- /dev/null +++ b/app/Models/ProductionOutput.php @@ -0,0 +1,49 @@ + + */ + protected function casts(): array + { + return [ + 'quantity' => 'integer', + 'pos' => 'integer', + ]; + } + + /** + * @return BelongsTo + */ + public function production(): BelongsTo + { + return $this->belongsTo(Production::class); + } + + /** + * @return BelongsTo + */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } +} diff --git a/app/Repositories/ProductionRepository.php b/app/Repositories/ProductionRepository.php index b96856a..e2da79e 100644 --- a/app/Repositories/ProductionRepository.php +++ b/app/Repositories/ProductionRepository.php @@ -15,7 +15,7 @@ class ProductionRepository public function listForIndex(?int $limit = 500): Collection { return Production::query() - ->with(['product', 'location']) + ->with(['product', 'location', 'outputs.product']) ->orderByDesc('produced_at') ->orderByDesc('id') ->when($limit !== null, fn ($q) => $q->limit($limit)) diff --git a/app/Services/ProductStockService.php b/app/Services/ProductStockService.php index 0477110..53a5b84 100644 --- a/app/Services/ProductStockService.php +++ b/app/Services/ProductStockService.php @@ -73,37 +73,58 @@ class ProductStockService } /** - * Verbucht den Produktbestand einer Produktion idempotent: gleicht die bereits gebuchte - * Menge dieser Produktion mit der Soll-Stückzahl ab und bucht nur die Differenz. + * Verbucht den Produktbestand einer Produktion idempotent, seit AP-28 **je Output-Produkt** + * (Produktionsergebnis): Soll = Summe der production_outputs je Produkt; gebucht wird nur + * die Differenz zur bereits gebuchten Menge (append-only). Die geplante Stückzahl der + * Produktion bucht nichts mehr. Entfernte Output-Produkte werden per Gegenbuchung + * auf null zurückgeführt. */ public function recordProductionStock(Production $production, ?int $userId = null): void { - $production->loadMissing('product'); + $production->load('outputs'); - $alreadyBooked = (int) ProductStockMovement::query() + $target = $production->outputs + ->groupBy('product_id') + ->map(fn ($rows) => (int) $rows->sum('quantity')); + + $booked = ProductStockMovement::query() ->where('source', 'production') ->where('reference_type', $production->getMorphClass()) ->where('reference_id', $production->getKey()) - ->selectRaw("COALESCE(SUM(CASE WHEN direction = 'in' THEN quantity ELSE -quantity END), 0) as net") - ->value('net'); + ->selectRaw("product_id, COALESCE(SUM(CASE WHEN direction = 'in' THEN quantity ELSE -quantity END), 0) as net") + ->groupBy('product_id') + ->pluck('net', 'product_id'); - $target = (int) $production->quantity; - $delta = $target - $alreadyBooked; + $productIds = $target->keys() + ->merge($booked->keys()) + ->map(fn ($id) => (int) $id) + ->unique() + ->values(); - if ($delta === 0) { + if ($productIds->isEmpty()) { return; } - $this->recordMovement( - $production->product, - $delta > 0 ? 'in' : 'out', - abs($delta), - $alreadyBooked === 0 ? __('Produktion') : __('Produktionskorrektur'), - 'production', - null, - $userId ?? $production->produced_by, - $production, - ); + $products = Product::query()->whereIn('id', $productIds)->get()->keyBy('id'); + + foreach ($productIds as $productId) { + $alreadyBooked = (int) ($booked[$productId] ?? 0); + $delta = (int) ($target[$productId] ?? 0) - $alreadyBooked; + if ($delta === 0 || ! isset($products[$productId])) { + continue; + } + + $this->recordMovement( + $products[$productId], + $delta > 0 ? 'in' : 'out', + abs($delta), + $alreadyBooked === 0 ? __('Produktion') : __('Produktionskorrektur'), + 'production', + null, + $userId ?? $production->produced_by, + $production, + ); + } } /** diff --git a/app/Services/ProductionService.php b/app/Services/ProductionService.php index 15e8d51..39bcc42 100644 --- a/app/Services/ProductionService.php +++ b/app/Services/ProductionService.php @@ -20,10 +20,11 @@ class ProductionService /** * @param array $data * @param array $ingredientLines + * @param array $outputs AP-28: Produktionsergebnis; leer ⇒ Fallback auf geplantes Produkt + Stückzahl */ - public function store(array $data, array $ingredientLines, int $userId): Production + public function store(array $data, array $ingredientLines, int $userId, array $outputs = []): Production { - return DB::transaction(function () use ($data, $ingredientLines, $userId) { + return DB::transaction(function () use ($data, $ingredientLines, $userId, $outputs) { $product = Product::query() ->with(['manufacturer_ingredients', 'packagings']) ->findOrFail($data['product_id']); @@ -72,20 +73,23 @@ class ProductionService $this->syncPackagingSnapshot($production, $product, $producedQty); + $this->persistOutputs($production, $product, $producedQty, $outputs); + $production->setRelation('product', $product); $this->productStockService->recordProductionStock($production, $userId); - return $production->fresh(['product', 'location', 'productionIngredients', 'productionPackagings']); + return $production->fresh(['product', 'location', 'productionIngredients', 'productionPackagings', 'outputs']); }); } /** * @param array $data * @param array $ingredientLines + * @param array $outputs AP-28: Produktionsergebnis; leer ⇒ Fallback auf geplantes Produkt + Stückzahl */ - public function updateProduction(Production $production, array $data, array $ingredientLines, int $userId): Production + public function updateProduction(Production $production, array $data, array $ingredientLines, int $userId, array $outputs = []): Production { - return DB::transaction(function () use ($production, $data, $ingredientLines, $userId) { + return DB::transaction(function () use ($production, $data, $ingredientLines, $userId, $outputs) { $product = Product::query() ->with(['manufacturer_ingredients', 'packagings']) ->findOrFail($data['product_id']); @@ -135,13 +139,67 @@ class ProductionService $production->productionPackagings()->delete(); $this->syncPackagingSnapshot($production, $product, $producedQty); + $production->outputs()->delete(); + $this->persistOutputs($production, $product, $producedQty, $outputs); + $production->setRelation('product', $product); $this->productStockService->recordProductionStock($production, $userId); - return $production->fresh(['product', 'location', 'productionIngredients', 'productionPackagings']); + return $production->fresh(['product', 'location', 'productionIngredients', 'productionPackagings', 'outputs']); }); } + /** + * AP-28: Produktionsergebnis-Zeilen persistieren. Ohne übergebene Outputs + * (Service-Aufrufer/Altverhalten) wird eine Zeile aus der Planung erzeugt — + * gleiche Semantik wie der Backfill der Bestandsdaten. + * + * @param array $outputs + */ + private function persistOutputs(Production $production, Product $plannedProduct, int $plannedQty, array $outputs): void + { + if ($outputs === []) { + $producedAt = $production->produced_at instanceof Carbon + ? $production->produced_at + : Carbon::parse((string) $production->produced_at); + + $outputs = [[ + 'product_id' => (int) $plannedProduct->id, + 'quantity' => $plannedQty, + 'batch_number' => $plannedProduct->suggestedBatchNumber($producedAt), + ]]; + } + + $productIds = array_map(fn (array $output) => (int) $output['product_id'], $outputs); + $products = Product::query()->whereIn('id', $productIds)->get()->keyBy('id'); + + foreach (array_values($outputs) as $index => $output) { + $productId = (int) $output['product_id']; + $quantity = (int) $output['quantity']; + + /** @var Product|null $outputProduct */ + $outputProduct = $products[$productId] ?? null; + if ($outputProduct === null) { + throw ValidationException::withMessages(['outputs' => __('Unbekanntes Produkt im Produktionsergebnis.')]); + } + if ($outputProduct->is_set) { + throw ValidationException::withMessages(['outputs' => __('Sets können nicht produziert werden. Bitte ein Einzelprodukt im Produktionsergebnis wählen.')]); + } + if ($quantity < 1) { + throw ValidationException::withMessages(['outputs' => __('Die produzierte Stückzahl muss je Ergebniszeile mindestens 1 sein.')]); + } + + $batchNumber = trim((string) ($output['batch_number'] ?? '')); + + $production->outputs()->create([ + 'product_id' => $productId, + 'quantity' => $quantity, + 'batch_number' => $batchNumber !== '' ? $batchNumber : null, + 'pos' => $index, + ]); + } + } + /** * Sets sind Bündel aus Einzelprodukten und werden nicht produziert. */ @@ -453,7 +511,9 @@ class ProductionService */ public function buildRecipePayload(Product $product, int $locationId, int $productionQuantity, ?int $excludeProductionId = null): array { - $product->loadMissing(['manufacturer_ingredients', 'packagings.packagingMaterial']); + $product->loadMissing(['manufacturer_ingredients', 'packagings.packagingMaterial', 'recipePhases']); + // AP-28: Zusatzspalten der INCI-Tabelle (Alternativname, Qualität, Regal) ohne N+1 laden. + $product->manufacturer_ingredients->load(['materialQuality', 'storageRoom', 'storageShelf', 'storageSlot']); $qty = max(1, $productionQuantity); $recipeRequired = ! (bool) $product->no_recipe_required; @@ -469,6 +529,10 @@ class ProductionService $ingredients[] = [ 'id' => $ing->id, 'name' => $ing->name, + 'alt_name' => $ing->alt_name, + 'quality' => $ing->materialQuality?->name, + 'storage_label' => $ing->storageLabel(), + 'phase_id' => $ing->pivot->recipe_phase_id !== null ? (int) $ing->pivot->recipe_phase_id : null, 'gram' => $hasGram ? (float) $gram : null, 'factor' => $factor, 'required_grams_total' => $req, @@ -506,11 +570,20 @@ class ProductionService 'name' => $product->name, 'shelf_life_type' => $product->shelf_life_type, 'shelf_life_months' => $product->shelf_life_months, + // AP-28: für die Chargen-Nr.-Vorbelegung im Produktionsergebnis. + 'batch_prefix' => $product->batch_prefix, ], 'recipe_required' => $recipeRequired, 'has_recipe' => $hasRecipe, 'location_id' => $locationId, 'production_quantity' => $qty, + // AP-28: Phasen der Hersteller-Rezeptur (AP-23) für die gruppierte Chargen-Erfassung. + 'phases' => $product->recipePhases + ->map(fn ($phase) => [ + 'id' => (int) $phase->id, + 'name' => $phase->name, + 'note' => $phase->note, + ])->values()->all(), 'ingredients' => $ingredients, 'packagings' => $packagings, ]; diff --git a/database/migrations/2026_07_06_153907_create_production_outputs_table.php b/database/migrations/2026_07_06_153907_create_production_outputs_table.php new file mode 100644 index 0000000..6bd8d55 --- /dev/null +++ b/database/migrations/2026_07_06_153907_create_production_outputs_table.php @@ -0,0 +1,48 @@ +id(); + $table->foreignId('production_id')->constrained('productions')->cascadeOnDelete(); + // products.id ist historisch int unsigned — kein foreignId()! + $table->unsignedInteger('product_id'); + $table->foreign('product_id')->references('id')->on('products')->cascadeOnDelete(); + $table->unsignedInteger('quantity'); + $table->string('batch_number', 100)->nullable(); + $table->unsignedInteger('pos')->default(0); + $table->timestamps(); + + $table->index(['production_id', 'pos']); + }); + + // Backfill (idempotent): Bestandsproduktionen erhalten eine Output-Zeile + // (geplantes Produkt + geplante Stückzahl), damit der bereits gebuchte + // Produktbestand konsistent bleibt. + DB::statement(<<<'SQL' + INSERT INTO production_outputs (production_id, product_id, quantity, batch_number, pos, created_at, updated_at) + SELECT p.id, p.product_id, p.quantity, NULL, 0, p.created_at, p.created_at + FROM productions p + WHERE NOT EXISTS ( + SELECT 1 FROM production_outputs po WHERE po.production_id = p.id + ) + SQL); + } + + public function down(): void + { + Schema::dropIfExists('production_outputs'); + } +}; diff --git a/dev/product management /entwicklungsplan-aktualisiert-06-07-2026.md b/dev/product management /entwicklungsplan-aktualisiert-06-07-2026.md index 54597c3..27933cd 100644 --- a/dev/product management /entwicklungsplan-aktualisiert-06-07-2026.md +++ b/dev/product management /entwicklungsplan-aktualisiert-06-07-2026.md @@ -105,8 +105,8 @@ Alle Punkte dieser älteren Runde sind in V4.0 eingeflossen und umgesetzt (URL-F | 6 | ✅ AP-24 | Chargen-Nr.-Präfix — **erledigt 06.07.2026** | – | 1 Tag | | 7 | ✅ OPT-3 + OPT-4 | Pagination + show()-Refactoring — **erledigt 06.07.2026** | – | 1,5–2,5 Tage | | 8 | ✅ AP-27 | Wareneingang: Chargen-Merge — **erledigt 06.07.2026** | OPT-4 | 1–2 Tage | -| 9 | ➡️ AP-28 | **NÄCHSTER SCHRITT:** Neue Produktions-Maske + Produktionsergebnis | AP-21, AP-23, AP-24, AP-27 | 5–8 Tage | -| 10 | AP-13 | Shop-Bestandsabzug bei Versand (inkl. Sets; Konzept aus V4.0) | AP-28 (saubere Buchungslogik) | 3–5 Tage | +| 9 | ✅ AP-28 | Neue Produktions-Maske + Produktionsergebnis — **erledigt 06.07.2026** | AP-21, AP-23, AP-24, AP-27 | 5–8 Tage | +| 10 | ➡️ AP-13 | **NÄCHSTER SCHRITT:** Shop-Bestandsabzug bei Versand (inkl. Sets; Konzept aus V4.0) | AP-28 (saubere Buchungslogik) | 3–5 Tage | | 11 | AP-14 | Audit-Trail | – | 2–3 Tage | | 12 | AP-16 | 2FA für Admins (Google Authenticator) | – | 2–3 Tage | | 13 | AP-15 | Blockbasierte Rechte (sehen/bearbeiten je Block) | AP-16 sinnvoll davor | 4–6 Tage | @@ -155,8 +155,8 @@ Die fachlichen Detailspezifikationen zu **AP-20, AP-21, AP-23, AP-24, AP-27, AP- | 06.07.2026 | **AP-24 (Chargen-Nr.-Präfix)** | Migration `2026_07_06_140324_add_batch_prefix_to_products_table` (`products.batch_prefix` VARCHAR(20) nullable); auf DB ausgeführt. `Product` fillable + Helper `suggestedBatchNumber(?Carbon $date)` = Präfix + Datum `ttmmjj` (Beispiel `TP1` + 15.06.26 ⇒ `TP1150626`; ohne Präfix ⇒ null; Default-Datum heute). Eingabefeld „Chargen-Nr.-Präfix" in der Warenwirtschaft-Card des Produktformulars (Hilfetext mit Beispiel); `ProductRepository::update()` trimmt (leer ⇒ null). Vorbelegung des editierbaren Chargen-Nr.-Felds folgt in AP-28 (Produktionsergebnis). Chargen-Format war bereits in der Hinweise-Doku dokumentiert (AP-21-Lieferung). | Neuer `tests/Feature/BatchPrefixTest.php` (6 grün): Speichern/Trim, leer ⇒ null, `ttmmjj`-Zusammensetzung, Default heute, ohne Präfix null, Formular-Render. Volle Suite: **184 grün, 657 Assertions** | | 06.07.2026 | **OPT-3 + OPT-4** | **OPT-3 (Index-Limits):** `StockEntryRepository::listForIndex(?int $receivedLimit = 500)` — offene Bestellungen immer vollständig, Eingegangene auf 500 gedeckelt (neueste zuerst, stabile Sekundärsortierung nach `id`); ungenutzte Eager-Loads `orderedByUser`/`receivedByUser` entfernt (nur `show` braucht sie). `ProductionRepository::listForIndex(?int $limit = 500)` analog. Beide Controller unterstützen `?all=1`; die Views zeigen bei erreichtem Limit einen Hinweis mit „Alle anzeigen"-Link. **OPT-4 (show() entschlackt):** Chargen-Restmengen (`chargesWithRemainingForIngredient()`, FEFO) und offene Bestellungen (`openOrdersForIngredient()`) in den `InventoryService` verschoben (Konstruktor-Injection `ProductionService`, kein Zyklus); `lastNetPricePerSupplier()` in `StockEntryRepository` als **eine** Window-Function-Query (`ROW_NUMBER() OVER (PARTITION BY supplier_id …)`, läuft auf MySQL 8 + SQLite) statt In-Memory-Schleife über alle Einkäufe. `RawMaterialStockController::show()` ist nur noch Orchestrierung. | `RawMaterialStockTest` +2 (letzter Netto-Preis je Lieferant aus neuestem Einkauf; Liste deckelt Eingegangene, Offene vollständig, `null` = alle). Volle Suite: **186 grün, 663 Assertions** | | 06.07.2026 | **AP-27 (Wareneingang: gleiche Charge zusammenführen)** | **Grundprinzip wie in §4 V5.0 bestätigt:** jeder Einkauf bleibt ein eigener `stock_entry` (Preis/Datum/Audit) — zusammengeführt werden nur Anzeige und Restbestand je `batch_number`. **Gruppierungsschlüssel:** `StockEntry::batchGroupKey()` (`b:`, Einträge ohne Chargennummer bleiben einzeln als `e:`). **Existenz-Check:** neuer JSON-Endpoint `GET api/stock-entries/batch-check` (`StockEntryController::batchCheck`, Route `admin.inventory.api.stock-entries.batch-check`; Parameter `ingredient_id`+`batch_number`+optional `exclude`) liefert `exists`/`purchases`/`remaining`/`best_before`/`location`/`quality_id`; die Buchen-Maske (Stufe 2, `stock-entries/show`) prüft beim Tippen (debounced) und zeigt „Charge bereits im Haus — Menge wird dieser Charge zugerechnet" inkl. Rest/Lagerort/MHD; **MHD + Qualität werden vorbelegt** (nur wenn leer, überschreibbar). **Rohstoffbestand-Detail:** `InventoryService::chargeGroupsForIngredient()` (baut auf `chargesWithRemainingForIngredient()` aus OPT-4, FEFO bleibt) — Chargen-Tabelle zeigt je Charge eine Zeile mit Summen-Rest, frühestem MHD und Badge „x Einkäufe"; Einzel-Einkäufe als eingerückte Unterzeilen („Einkauf vom …"). **Produktion:** Recipe-Payload je Rohstoff um `batches` erweitert (`ProductionService::batchOptionsForEntries()`, eine Option je Charge mit Summen-Rest; `stock_entries` bleibt für Audit/Kompatibilität); Charge-Dropdown im Formular listet Chargen statt Einzel-Einkäufe (`ingredient_lines[i][batch_key]`); `StoreProductionRequest` akzeptiert `batch_key` **oder** `stock_entry_id` (`required_without`); `ProductionService::expandBatchLines()` löst Chargen-Zeilen **FEFO** auf die Einzel-Einkäufe auf (Überhang auf letzten Eintrag — kein neuer Hardstop, konsistent zum Bestandsverhalten), Controller ruft sie vor `store`/`updateProduction` auf (bei Update mit `exclude_production`); **Service-API unverändert** (`store()`/`updateProduction()` weiter je `stock_entry_id` — bestehende Tests/Aufrufer unberührt). Edit/Copy fassen bestehende Zeilen je Charge zusammen (Summe je `batch_key`). Hinweise-Doku fortgeschrieben. | Neuer `tests/Feature/StockEntryChargeMergeTest.php` (7 grün, 35 Assertions): Gruppierung + separate Einkäufe, Endpoint (Treffer/Fehlschlag), Detailansicht summiert + Unterzeilen, Payload-`batches`, FEFO-Split, Passthrough + unbekannte Charge, HTTP-Produktion bucht auf 2 Einkäufe. Volle Suite: **193 grün, 698 Assertions** | -| _offen_ | AP-28 | Neue Produktions-Maske + Produktionsergebnis | – | +| 06.07.2026 | **AP-28 (Neue Produktions-Maske + Produktionsergebnis)** | **DB:** Migration `2026_07_06_153907_create_production_outputs_table` — Tabelle `production_outputs` (`production_id` FK cascade als `foreignId` — productions.id ist bigint; `product_id` als **`unsignedInteger`+foreign** — products.id historisch int!; `quantity` UINT, `batch_number` VARCHAR(100) nullable, `pos`; Index (production_id, pos)) **inkl. idempotentem Backfill** (Bestandsproduktionen ⇒ je eine Output-Zeile aus geplantem Produkt + Stückzahl, `NOT EXISTS`-Guard); auf DB ausgeführt (3 Produktionen backgefüllt). **Buchung:** `ProductStockService::recordProductionStock()` bucht jetzt **je Output-Produkt** (Soll = Summe `production_outputs.quantity` je product_id, idempotenter Differenz-Abgleich, append-only; entfernte Output-Produkte werden per Gegenbuchung auf null zurückgeführt) — die geplante Stückzahl bucht **nichts** mehr. **Service:** `ProductionService::store()/updateProduction()` um `outputs[]` erweitert (4. Parameter, Default `[]` ⇒ **Fallback auf geplantes Produkt + Stückzahl** mit `suggestedBatchNumber(produced_at)` — hält Alt-Aufrufer/Tests stabil und entspricht der Backfill-Semantik); `persistOutputs()` validiert (Produkt existiert, kein Set, Menge ≥ 1), Update ersetzt Outputs vollständig. **HTTP:** `StoreProductionRequest` verlangt `outputs` (≥ 1 Zeile; product_id exists, quantity ≥ 1, batch_number nullable). **Payload:** `buildRecipePayload()` liefert zusätzlich `product.batch_prefix`, `phases[]` (AP-23) und je Rohstoff `alt_name`/`quality`/`storage_label`/`phase_id` (Relationen eager, kein N+1). **Maske** (`_form_fields` + `_scripts`): Planungs-Stückzahl umbenannt („Geplante Stückzahl (Planung)" + Hinweis „bucht keinen Bestand"), **INCI-Liste** (Name, Alternativname, Qualität, Anteil %, Gramm gesamt, Regal, Summenzeile), Chargen-Zuordnung **nach Phasen gruppiert** (Phasen-Block mit Name + Notiz; Rohstoffe ohne Phase in eigenem Block), **Status-Ampel** je Rohstoff (grün = Summe ≈ Soll ± 0,02 g), **Produktionsergebnis-Repeater** (Produkt-Select, Stückzahl, Chargen-Nr. mit Auto-Vorbelegung Präfix+`ttmmjj` aus Produktionsdatum — folgt Produkt-/Datumswechsel bis zur manuellen Eingabe; Default-Zeile spiegelt die Planung, bis sie angefasst wird; old()/Edit/Copy-Vorbelegung). **Blade-Falle erneut:** ein Kommentar mit literalem `@endphp` im `@php`-Block beendete den Block vorzeitig — Kommentartext angepasst. **Show:** neue Karte „Produktionsergebnis"; Planung als solche gekennzeichnet. **Index:** Spalte „Ergebnis" (Outputs summiert, Fallback Planung). Hinweise-Doku fortgeschrieben. | Neuer `tests/Feature/ProductionOutputTest.php` (8 grün, 32 Assertions): Mehrfach-Outputs buchen je Produkt / Planung bucht nichts, Service-Fallback inkl. Chargen-Nr., Differenz-Korrektur + Entfernen ⇒ Rückbuchung, Rohstoffverbrauch bleibt chargenbasiert, HTTP-Pflicht ≥ 1 Output, Set/Menge-Validierung, Payload-Felder, Masken-Render. Zwei bestehende HTTP-Tests um `outputs` ergänzt. Volle Suite: **201 grün, 731 Assertions** | | _offen_ | AP-13 | Shop-Bestandsabzug | – | | _offen_ | AP-14/15/16/17 | Audit-Trail, Blockrechte, 2FA, WaWi-Einstellungen | – | -> **➡️ HIER GEHT ES WEITER: AP-28 (neue Produktions-Maske + Produktionsergebnis)** gemäß V5.0-Spezifikation (§4 der V5.0-Datei) + Kundenentscheid §4 Punkt 4 (Output-Produkte dürfen andere Produkte sein): Tabelle `production_outputs` (production_id, product_id, quantity, batch_number, pos — **FKs als `unsignedInteger`, products.id ist historisch int!**), `ProductStockService::recordProductionStock` bucht aus Outputs (idempotenter Diff je Produkt + Backfill-Migration für Bestandsproduktionen), Planungs-Stückzahl wird informativ, Chargen-Erfassung nach Rezeptur-**Phasen** gruppiert (AP-23), Chargen-Nr. je Output vorbelegt via `Product::suggestedBatchNumber(produced_at)` (AP-24, editierbar), INCI-Tabelle mit Alternativname + Lagerort/Regal (AP-21), Charge-Dropdown nutzt die AP-27-Batches. +> **➡️ HIER GEHT ES WEITER: AP-13 (Shop-Bestandsabzug bei Versand, inkl. Sets)** gemäß Konzept aus V4.0 (`entwicklungsplan-aktualisiert-02-06-2026.md`), mit der V6.0-Änderung: Buchung gegen die **`production_outputs`-basierten Bestände** (AP-28). Kern: beim **gebuchten Versand** je Bestellposition einen Abgang `source='sale'` im Produktbestand erzeugen (Sets in ihre Einzelprodukte auflösen); Stornos/Retouren als Gegenbuchung. `monthlyConsumptionByProduct()` berücksichtigt `source='sale'` bereits automatisch. Danach AP-14 (Audit-Trail) → AP-16 (2FA) → AP-15 (Blockrechte) → AP-17 (WaWi-Einstellungen). diff --git a/resources/docs/hinweise.md b/resources/docs/hinweise.md index 10eea1b..2b50108 100644 --- a/resources/docs/hinweise.md +++ b/resources/docs/hinweise.md @@ -111,12 +111,22 @@ (bestellt) und erhöht den Bestand erst, wenn beim Einkauf der **Wareneingang gebucht** wird (Stufe 2: eingegangen, mit Menge/Charge/MHD). Bis dahin erscheint die Menge nur unter „Offen bestellt". -- **Produktbestand wächst automatisch durch Produktion.** Jede gebuchte - Produktion erzeugt einen Eingang in Höhe der produzierten Stückzahl; eine - spätere Mengenkorrektur der Produktion wird als Differenz nachgebucht. Manuelle - Korrekturen erfolgen ausschließlich über Gegenbuchungen (`+`/`−`) – Einträge - der Historie werden nicht gelöscht. Verkaufsabgänge folgen mit der - Shop-Anbindung. +- **Produktbestand wächst automatisch durch Produktion — aus dem + Produktionsergebnis.** Die Stückzahl oben in der Produktions-Maske ist **nur + Planung** und bucht nichts. Ganz unten unter **„Produktionsergebnis"** wird + erfasst, was real entstanden ist — auch **mehrere Produkte** je Produktion + (z. B. Plan 50×50 ml → Ergebnis 40×50 ml + 50×5 ml). Jede Ergebniszeile hat + eine **Chargen-Nr.**, die aus Produkt-Präfix + Produktionsdatum (`ttmmjj`) + vorbelegt wird und editierbar bleibt. Eine spätere Korrektur des Ergebnisses + wird je Produkt als Differenz nachgebucht. Manuelle Korrekturen erfolgen + ausschließlich über Gegenbuchungen (`+`/`−`) – Einträge der Historie werden + nicht gelöscht. Verkaufsabgänge folgen mit der Shop-Anbindung. +- **Produktions-Maske:** zeigt die Hersteller-Rezeptur als **INCI-Liste** mit + Alternativname, Qualität, Anteil %, Gesamt-Gramm und **Regal** (Lagerort); + die Chargen-Zuordnung ist nach **Rezeptur-Phasen** gegliedert (inkl. + Phasen-Notiz), mit Status-Ampel je Rohstoff (grün = Soll erfüllt). Der + **Rohstoffbestand** berechnet sich unverändert aus den je Charge + eingetragenen Ist-Mengen — unabhängig vom Produktionsergebnis. - **Bestellweg je INCI-Lieferant:** Am Inhaltsstoff wird pro Lieferant eine Shop-URL gepflegt („+ weiteren Lieferanten anlegen"). Ist die URL **leer**, gilt für diesen Lieferanten **Bestellung per Mail** – der Rohstoffbestand diff --git a/resources/views/admin/inventory/productions/_form_fields.blade.php b/resources/views/admin/inventory/productions/_form_fields.blade.php index 2f5dd60..77cccd7 100644 --- a/resources/views/admin/inventory/productions/_form_fields.blade.php +++ b/resources/views/admin/inventory/productions/_form_fields.blade.php @@ -1,4 +1,19 @@ -@php($isEdit = $isEdit ?? false) +@php + // Achtung: in diesem Template nur die Block-Form von php-Direktiven verwenden (Blade-Compiler-Falle, siehe AP-23). + $isEdit = $isEdit ?? false; + + // AP-28: Vorbelegung der Produktionsergebnis-Zeilen (old() > bestehende Outputs > leer = Default aus Planung per JS). + $initialOutputs = old('outputs'); + if (! $initialOutputs) { + $initialOutputs = ($model && $model->relationLoaded('outputs') && $model->outputs->isNotEmpty()) + ? $model->outputs->map(fn ($o) => [ + 'product_id' => $o->product_id, + 'quantity' => $o->quantity, + 'batch_number' => $o->batch_number, + ])->values()->all() + : []; + } +@endphp
@@ -36,9 +51,10 @@ @enderror
- + + {{ __('Nur Planung — bucht keinen Bestand. Der Produktbestand entsteht aus dem Produktionsergebnis unten.') }} @error('quantity')
{{ $message }}
@enderror @@ -49,6 +65,27 @@ + +{{-- AP-28: Produktionsergebnis — treibt den Produktbestand (mehrere Output-Produkte möglich) --}} +
+
{{ __('Produktionsergebnis') }}
+
+ {{ __('Die Planung oben ist unverbindlich. Der Produktbestand entsteht ausschließlich aus dem Produktionsergebnis — auch mehrere Produkte möglich (z. B. 40×50 ml + 50×5 ml).') }} +
+@error('outputs') +
{{ $message }}
+@enderror +
+
{{ __('Produkt') }}
+
{{ __('Produzierte Stückzahl') }}
+
{{ __('Chargen-Nr.') }}
+
+
+
+ + + diff --git a/resources/views/admin/inventory/productions/_scripts.blade.php b/resources/views/admin/inventory/productions/_scripts.blade.php index da430f4..a4a88d4 100644 --- a/resources/views/admin/inventory/productions/_scripts.blade.php +++ b/resources/views/admin/inventory/productions/_scripts.blade.php @@ -30,6 +30,14 @@ return Number(value).toLocaleString('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 2}); } + function parseGerman(value) { + value = String(value || '').trim(); + if (!value) { + return 0; + } + return parseFloat(value.replace(/\./g, '').replace(',', '.')) || 0; + } + // AP-27: Dropdown je Charge (batch_number) zusammengeführt — Restbestand ist die Summe // über alle Einkäufe der Charge; die FEFO-Verteilung übernimmt der Server. function buildStockSelect(idx, batches, preselectedBatchKey) { @@ -63,7 +71,7 @@ var $colQty = $('
'); var $group = $('
'); - $group.append($('').val(preselectedQty || '')); + $group.append($('').val(preselectedQty || '')); $group.append('
g
'); $colQty.append($group); $row.append($colQty); @@ -71,6 +79,30 @@ $wrap.find('.recipe-rows').first().append($row); } + // AP-28: Status-Ampel je Rohstoff — grün, wenn Summe der Chargen-Mengen dem Soll entspricht. + function updateIngredientStatus($wrap) { + var ing = $wrap.data('recipe-ing'); + var $dot = $wrap.find('.recipe-status').first(); + if (!ing || ing.gram == null || !$dot.length) { + return; + } + var qty = parseInt($('#quantity').val(), 10) || 1; + var soll = ing.gram * (ing.factor || 1) * qty; + var sum = 0; + $wrap.find('.recipe-qty-input').each(function () { + sum += parseGerman($(this).val()); + }); + var ok = Math.abs(sum - soll) <= 0.02; + $dot.css('color', ok ? '#28a745' : '#fd7e14') + .attr('title', ok ? '{{ __('Soll erfüllt') }}' : '{{ __('Summe weicht vom Soll ab') }}'); + } + + function updateAllStatuses() { + $('#recipe-ingredient-lines [data-ingredient-id]').each(function () { + updateIngredientStatus($(this)); + }); + } + function recomputeSoll() { var qty = parseInt($('#quantity').val(), 10) || 1; $('#recipe-ingredient-lines [data-ingredient-id]').each(function () { @@ -82,6 +114,98 @@ var soll = ing.gram * (ing.factor || 1) * qty; $wrap.find('.recipe-soll').text(fmt(soll)); }); + renderInciTable(); + updateAllStatuses(); + } + + // AP-28: INCI-Liste der Hersteller-Rezeptur (Alternativname, Qualität, Anteil, Gramm, Regal). + function renderInciTable() { + var $body = $('#recipe-inci-body').empty(); + var $foot = $('#recipe-inci-foot').empty(); + if (!recipeIngredients.length) { + $('#recipe-inci').hide(); + return; + } + var qty = parseInt($('#quantity').val(), 10) || 1; + var totalPercent = 0; + var totalGrams = 0; + recipeIngredients.forEach(function (ing) { + var grams = ing.gram != null ? ing.gram * (ing.factor || 1) * qty : null; + if (ing.gram != null) { + totalPercent += ing.gram; + } + if (grams != null) { + totalGrams += grams; + } + $body.append($('') + .append($('').text(ing.name)) + .append($('').text(ing.alt_name || '—')) + .append($('').text(ing.quality || '—')) + .append($('').text(ing.gram != null ? fmt(ing.gram) : '—')) + .append($('').text(grams != null ? fmt(grams) : '—')) + .append($('').text(ing.storage_label || '—'))); + }); + $foot.append($('') + .append($('').text('{{ __('Summe') }}')) + .append('') + .append($('').text(fmt(totalPercent) + ' %')) + .append($('').text(fmt(totalGrams) + ' g')) + .append('')); + $('#recipe-inci').show(); + } + + // AP-28: Chargen-Erfassung nach Rezeptur-Phasen (AP-23) gruppiert. + function buildPhaseGroups(data) { + var phases = data.phases || []; + var ingredients = (data.ingredients || []).filter(function (ing) { return ing.gram != null; }); + if (!phases.length) { + return ingredients.length ? [{name: null, note: null, ingredients: ingredients}] : []; + } + var groups = []; + var byPhase = {}; + phases.forEach(function (ph) { + var group = {name: ph.name, note: ph.note, ingredients: []}; + byPhase[String(ph.id)] = group; + groups.push(group); + }); + var loose = {name: null, note: null, ingredients: []}; + ingredients.forEach(function (ing) { + var group = ing.phase_id != null ? byPhase[String(ing.phase_id)] : null; + (group || loose).ingredients.push(ing); + }); + if (loose.ingredients.length) { + groups.push(loose); + } + return groups.filter(function (g) { return g.ingredients.length > 0; }); + } + + function renderIngredientWrap(ing, $container) { + var qty = parseInt($('#quantity').val(), 10) || 1; + var soll = ing.gram * (ing.factor || 1) * qty; + var $wrap = $('
'); + $wrap.data('recipe-ing', ing); + var $head = $('
'); + var $title = $('').text(ing.name); + if (ing.alt_name) { + $title.append($('').text(' · ' + ing.alt_name)); + } + $head.append($title); + $head.append($('') + .html('{{ __('Soll') }}: ' + fmt(soll) + ' g ●')); + $wrap.append($head); + $wrap.append('
'); + $wrap.append(''); + $container.append($wrap); + + var existing = existingLines[String(ing.id)] || []; + if (existing.length > 0) { + existing.forEach(function (ex) { + addIngredientRow($wrap, ing, ex.batch_key, ex.quantity_used); + }); + } else { + addIngredientRow($wrap, ing); + } + updateIngredientStatus($wrap); } function renderRecipe(data) { @@ -93,6 +217,7 @@ $('#recipe-warning').hide(); $('#btn-submit-production').prop('disabled', false); $('#recipe-area').show(); + $('#recipe-inci').hide(); $('#recipe-hint') .text('{{ __('Dieses Produkt benötigt keine Rezeptur. Es wird ohne Rohstoffverbrauch produziert.') }}') .removeClass('text-danger').addClass('text-muted'); @@ -118,38 +243,35 @@ $('#btn-submit-production').prop('disabled', false); $('#recipe-area').show(); - var qty = parseInt($('#quantity').val(), 10) || 1; - var hintParts = []; - var hasMissingGram = false; + renderInciTable(); + var hintParts = []; recipeIngredients.forEach(function (ing) { if (ing.gram == null) { - hasMissingGram = true; hintParts.push(ing.name + ': {{ __('Anteil in der Hersteller-Rezeptur fehlt') }}'); - return; - } - var soll = ing.gram * (ing.factor || 1) * qty; - var $wrap = $('
'); - $wrap.data('recipe-ing', ing); - var $head = $('
'); - $head.append($('').text(ing.name)); - $head.append($('').html('{{ __('Soll') }}: ' + fmt(soll) + ' g')); - $wrap.append($head); - $wrap.append('
'); - $wrap.append(''); - $('#recipe-ingredient-lines').append($wrap); - - var existing = existingLines[String(ing.id)] || []; - if (existing.length > 0) { - existing.forEach(function (ex) { - addIngredientRow($wrap, ing, ex.batch_key, ex.quantity_used); - }); - } else { - addIngredientRow($wrap, ing); } }); - if (hasMissingGram) { + var groups = buildPhaseGroups(data); + groups.forEach(function (group) { + var $target = $('#recipe-ingredient-lines'); + if (group.name != null) { + var $block = $('
'); + var $header = $('
'); + $header.append($('').text(group.name)); + if (group.note) { + $header.append($('
').text(group.note)); + } + $block.append($header); + $('#recipe-ingredient-lines').append($block); + $target = $block; + } + group.ingredients.forEach(function (ing) { + renderIngredientWrap(ing, $target); + }); + }); + + if (hintParts.length) { $('#recipe-hint').text(hintParts.join(' · ')).removeClass('text-muted').addClass('text-danger'); } else { $('#recipe-hint') @@ -173,6 +295,11 @@ return; } addIngredientRow($wrap, ing); + updateIngredientStatus($wrap); + }); + + $(document).on('input change', '.recipe-qty-input', function () { + updateIngredientStatus($(this).closest('[data-ingredient-id]')); }); function loadRecipe() { @@ -209,10 +336,139 @@ } }); + // ------------------------------------------------------------------ + // AP-28: Produktionsergebnis-Repeater — treibt den Produktbestand. + // Standard: eine Zeile, die der Planung folgt, bis sie manuell geändert wird. + // Chargen-Nr. wird aus Produkt-Präfix + Produktionsdatum (ttmmjj) vorbelegt. + // ------------------------------------------------------------------ + var outputIndex = 0; + + function batchDateSuffix() { + var raw = String($('#produced_at').val() || '').trim(); + var m = raw.match(/^(\d{2})\.(\d{2})\.(\d{4})$/); + if (!m) { + return null; + } + return m[1] + m[2] + m[3].slice(2); + } + + function suggestedBatch($select) { + var prefix = String($select.find('option:selected').data('batch-prefix') || '').trim(); + var suffix = batchDateSuffix(); + if (!prefix || !suffix) { + return ''; + } + return prefix + suffix; + } + + function refreshBatch($row) { + var $batch = $row.find('.output-batch'); + if ($batch.data('manual') && String($batch.val() || '').trim() !== '') { + return; + } + $batch.val(suggestedBatch($row.find('.output-product'))).data('manual', false); + } + + function addOutputRow(data, isAuto) { + var idx = outputIndex++; + var html = $('#output-row-template').html().replace(/__INDEX__/g, idx); + var $row = $(html); + $row.data('auto', !!isAuto); + + if (data && data.product_id != null) { + $row.find('.output-product').val(String(data.product_id)); + } else { + var planned = $('#product_id').val(); + if (planned) { + $row.find('.output-product').val(String(planned)); + } + } + if (data && data.quantity != null) { + $row.find('.output-quantity').val(data.quantity); + } else { + $row.find('.output-quantity').val($('#quantity').val() || 1); + } + if (data && data.batch_number) { + $row.find('.output-batch').val(data.batch_number).data('manual', true); + } + + $('#production-outputs').append($row); + if (!data || !data.batch_number) { + refreshBatch($row); + } + } + + $('#btn-add-output').on('click', function (e) { + e.preventDefault(); + addOutputRow(null, false); + }); + + $(document).on('click', '.output-remove', function (e) { + e.preventDefault(); + if ($('#production-outputs .output-row').length <= 1) { + return; + } + $(this).closest('.output-row').remove(); + }); + + $(document).on('change', '.output-product', function () { + var $row = $(this).closest('.output-row'); + $row.data('auto', false); + refreshBatch($row); + }); + + $(document).on('input change', '.output-quantity', function () { + $(this).closest('.output-row').data('auto', false); + }); + + $(document).on('input', '.output-batch', function () { + $(this).data('manual', String($(this).val() || '').trim() !== ''); + }); + + // Planung oben spiegelt sich in der Auto-Zeile, solange diese unberührt ist. + $('#product_id').on('change', function () { + var val = $(this).val(); + $('#production-outputs .output-row').each(function () { + var $row = $(this); + if ($row.data('auto') && val) { + $row.find('.output-product').val(String(val)); + refreshBatch($row); + } + }); + }); + + $('#quantity').on('change input', function () { + var val = $(this).val(); + $('#production-outputs .output-row').each(function () { + var $row = $(this); + if ($row.data('auto')) { + $row.find('.output-quantity').val(val || 1); + } + }); + }); + + $('#produced_at').on('change', function () { + $('#production-outputs .output-row').each(function () { + refreshBatch($(this)); + }); + }); + + function initOutputs() { + var initial = $('#production-outputs').data('initial') || []; + if (Array.isArray(initial) && initial.length > 0) { + initial.forEach(function (row) { + addOutputRow(row, false); + }); + } else { + addOutputRow(null, true); + } + } + $(document).ready(function () { if ($('#product_id').val() && $('#location_id').val()) { loadRecipe(); } + initOutputs(); }); })(jQuery); diff --git a/resources/views/admin/inventory/productions/index.blade.php b/resources/views/admin/inventory/productions/index.blade.php index e1843f7..6aa94b5 100644 --- a/resources/views/admin/inventory/productions/index.blade.php +++ b/resources/views/admin/inventory/productions/index.blade.php @@ -24,7 +24,7 @@ {{ __('Datum') }} {{ __('Produkt') }} - {{ __('Stück') }} + {{ __('Ergebnis') }} {{ __('Standort') }} {{ __('MHD-Hinweis') }} @@ -45,7 +45,14 @@ {{ $row->produced_at?->format('d.m.Y') }} {{ $row->product?->name ?? '—' }} - {{ $row->quantity }} + + {{-- AP-28: reale Outputs statt Planungs-Stückzahl --}} + @if($row->outputs->isNotEmpty()) + {{ $row->outputs->map(fn ($o) => $o->quantity . '× ' . ($o->product?->name ?? '—'))->implode(', ') }} + @else + {{ $row->quantity }} ({{ __('Planung') }}) + @endif + {{ $row->location?->name ?? '—' }} @if($row->mhd_warning) diff --git a/resources/views/admin/inventory/productions/show.blade.php b/resources/views/admin/inventory/productions/show.blade.php index d8f10a9..2e239e1 100644 --- a/resources/views/admin/inventory/productions/show.blade.php +++ b/resources/views/admin/inventory/productions/show.blade.php @@ -32,8 +32,8 @@ {{ $model->produced_at?->format('d.m.Y') }}
- {{ __('Stückzahl') }} - {{ $model->quantity }} + {{ __('Geplante Stückzahl') }} + {{ $model->quantity }} ({{ __('Planung — bucht keinen Bestand') }})
{{ __('Standort') }} @@ -63,6 +63,37 @@
+ {{-- AP-28: Produktionsergebnis — diese Zeilen buchen den Produktbestand --}} +
+
{{ __('Produktionsergebnis') }}
+
+
+ + + + + + + + + + @forelse($model->outputs as $output) + + + + + + @empty + + + + @endforelse + +
{{ __('Produkt') }}{{ __('Produzierte Stückzahl') }}{{ __('Chargen-Nr.') }}
{{ $output->product?->name ?? '—' }}{{ $output->quantity }}{{ $output->batch_number ?: '—' }}
{{ __('Kein Produktionsergebnis erfasst.') }}
+
+
+
+
{{ __('Rohstoff-Verbrauch (Chargen)') }}
diff --git a/tests/Feature/ProductionManufacturerRecipeTest.php b/tests/Feature/ProductionManufacturerRecipeTest.php index 11c641f..6422e14 100644 --- a/tests/Feature/ProductionManufacturerRecipeTest.php +++ b/tests/Feature/ProductionManufacturerRecipeTest.php @@ -354,6 +354,10 @@ test('produktion per http speichert eigenprodukt ohne chargen', function () { 'location_id' => $location->id, 'produced_at' => '01.03.2026', 'quantity' => 12, + // AP-28: Produktionsergebnis ist Pflicht (bucht den Produktbestand). + 'outputs' => [ + ['product_id' => $product->id, 'quantity' => 12, 'batch_number' => null], + ], ]); $response->assertRedirect(); @@ -361,6 +365,10 @@ test('produktion per http speichert eigenprodukt ohne chargen', function () { 'product_id' => $product->id, 'quantity' => 12, ]); + $this->assertDatabaseHas('production_outputs', [ + 'product_id' => $product->id, + 'quantity' => 12, + ]); }); test('produktentwicklung platzhalter rendert briefing-hinweis', function () { diff --git a/tests/Feature/ProductionOutputTest.php b/tests/Feature/ProductionOutputTest.php new file mode 100644 index 0000000..6d807c8 --- /dev/null +++ b/tests/Feature/ProductionOutputTest.php @@ -0,0 +1,325 @@ +firstOrCreate( + ['code' => 'DE'], + [ + 'phone' => '00', + 'en' => 'Germany', + 'de' => 'Deutschland', + 'es' => 'Germany', + 'fr' => 'Germany', + 'it' => 'Germany', + 'ru' => 'Germany', + 'active' => true, + ] + ); + + $user = User::query()->create([ + 'email' => uniqid('ap28_', true).'@test.example', + 'password' => bcrypt('password'), + ]); + $user->forceFill([ + 'admin' => $adminLevel, + 'confirmed' => true, + 'active' => true, + 'wizard' => 100, + 'blocked' => false, + ])->save(); + + return $user->fresh(); +} + +function ap28MakeProduct(string $name, array $overrides = []): Product +{ + return Product::query()->create(array_merge([ + 'name' => $name, + 'title' => $name, + 'active' => true, + ], $overrides)); +} + +/** + * Produkt ohne Rezeptur-Pflicht — Produktionen brauchen dann keine Chargen-Zeilen. + */ +function ap28MakeSimpleProduct(string $name, array $overrides = []): Product +{ + return ap28MakeProduct($name, array_merge(['no_recipe_required' => true], $overrides)); +} + +function ap28Stock(int $productId): int +{ + return app(ProductStockService::class)->currentStock($productId); +} + +test('mehrere outputs buchen produktbestand je produkt, planung bucht nichts', function () { + $user = ap28MakeUser(); + $location = Location::factory()->create(); + + $planned = ap28MakeSimpleProduct('AP28-Plan-50ml'); + $small = ap28MakeSimpleProduct('AP28-5ml'); + + $production = app(ProductionService::class)->store( + [ + 'product_id' => $planned->id, + 'location_id' => $location->id, + 'produced_at' => '2026-07-01', + 'quantity' => 50, // Planung — darf nicht buchen + 'notes' => null, + ], + [], + $user->id, + [ + ['product_id' => $planned->id, 'quantity' => 40, 'batch_number' => 'GS1010726'], + ['product_id' => $small->id, 'quantity' => 50, 'batch_number' => 'GS2010726'], + ] + ); + + expect($production->outputs)->toHaveCount(2); + expect(ap28Stock($planned->id))->toBe(40); + expect(ap28Stock($small->id))->toBe(50); +}); + +test('service ohne outputs faellt auf planung zurueck (backfill-semantik)', function () { + $user = ap28MakeUser(); + $location = Location::factory()->create(); + + $product = ap28MakeSimpleProduct('AP28-Fallback', ['batch_prefix' => 'TP1']); + + $production = app(ProductionService::class)->store( + [ + 'product_id' => $product->id, + 'location_id' => $location->id, + 'produced_at' => '2026-06-15', + 'quantity' => 12, + 'notes' => null, + ], + [], + $user->id + ); + + expect($production->outputs)->toHaveCount(1); + expect($production->outputs->first()->quantity)->toBe(12); + // Chargen-Nr. der Fallback-Zeile = Präfix + ttmmjj des Produktionsdatums (AP-24). + expect($production->outputs->first()->batch_number)->toBe('TP1150626'); + expect(ap28Stock($product->id))->toBe(12); +}); + +test('bearbeiten korrigiert produktbestand per differenz je output-produkt', function () { + $user = ap28MakeUser(); + $location = Location::factory()->create(); + + $planned = ap28MakeSimpleProduct('AP28-Edit-50ml'); + $small = ap28MakeSimpleProduct('AP28-Edit-5ml'); + + $service = app(ProductionService::class); + + $production = $service->store( + ['product_id' => $planned->id, 'location_id' => $location->id, 'produced_at' => '2026-07-01', 'quantity' => 50, 'notes' => null], + [], + $user->id, + [['product_id' => $planned->id, 'quantity' => 50, 'batch_number' => null]] + ); + + expect(ap28Stock($planned->id))->toBe(50); + + // Ergebnis nachträglich: nur 40×50 ml, dafür 50×5 ml. + $production = $service->updateProduction( + $production, + ['product_id' => $planned->id, 'location_id' => $location->id, 'produced_at' => '2026-07-01', 'quantity' => 50, 'notes' => null], + [], + $user->id, + [ + ['product_id' => $planned->id, 'quantity' => 40, 'batch_number' => null], + ['product_id' => $small->id, 'quantity' => 50, 'batch_number' => null], + ] + ); + + expect(ap28Stock($planned->id))->toBe(40); + expect(ap28Stock($small->id))->toBe(50); + + // Append-only: Korrektur als Gegenbuchung, keine Historie gelöscht. + expect(ProductStockMovement::query()->where('product_id', $planned->id)->count())->toBe(2); + + // Output-Produkt komplett entfernen ⇒ Bestand wird auf null zurückgeführt. + $service->updateProduction( + $production->fresh(), + ['product_id' => $planned->id, 'location_id' => $location->id, 'produced_at' => '2026-07-01', 'quantity' => 50, 'notes' => null], + [], + $user->id, + [['product_id' => $planned->id, 'quantity' => 40, 'batch_number' => null]] + ); + + expect(ap28Stock($small->id))->toBe(0); +}); + +test('rohstoffverbrauch bleibt chargenbasiert und unabhaengig vom ergebnis', function () { + $user = ap28MakeUser(); + $location = Location::factory()->create(); + $supplier = Supplier::factory()->create(); + + $ing = Ingredient::query()->create([ + 'name' => 'AP28-Rohstoff', + 'trans_name' => '', + 'inci' => 'AP28-Rohstoff', + 'trans_inci' => '', + 'effect' => '', + 'trans_effect' => '', + 'active' => true, + 'pos' => 0, + ]); + + $stock = StockEntry::query()->create([ + 'entry_type' => 'ingredient', + 'ingredient_id' => $ing->id, + 'supplier_id' => $supplier->id, + 'location_id' => $location->id, + 'unit' => 'gram', + 'ordered_by' => $user->id, + 'ordered_at' => '2026-01-01', + 'ordered_quantity' => 5000, + 'status' => 'received', + 'received_by' => $user->id, + 'received_at' => '2026-01-15', + 'received_quantity' => 5000, + 'batch_number' => 'AP28-CHARGE', + 'best_before' => '2030-12-31', + ]); + + $product = ap28MakeProduct('AP28-Rezeptur-Produkt'); + ProductIngredient::query()->create([ + 'product_id' => $product->id, + 'ingredient_id' => $ing->id, + 'pos' => 0, + 'gram' => 10, + 'factor' => 1.0, + 'recipe_type' => 'manufacturer', + ]); + + // Plan 2 Stück (Soll 20 g) — Ergebnis weicht ab (nur 1 Stück), Verbrauch bleibt bei den Chargen-Werten. + $production = app(ProductionService::class)->store( + ['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-07-01', 'quantity' => 2, 'notes' => null], + [['ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'quantity_used' => '20']], + $user->id, + [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]] + ); + + expect((float) $production->productionIngredients->first()->quantity_used)->toBe(20.0); + expect(ap28Stock($product->id))->toBe(1); +}); + +test('http-validierung verlangt mindestens eine ergebniszeile', function () { + $user = ap28MakeUser(); + $location = Location::factory()->create(); + $product = ap28MakeSimpleProduct('AP28-HTTP-Pflicht'); + + $this->actingAs($user, 'user'); + + $response = $this->post(route('admin.inventory.productions.store'), [ + 'product_id' => $product->id, + 'location_id' => $location->id, + 'produced_at' => '01.07.2026', + 'quantity' => 10, + ]); + + $response->assertSessionHasErrors('outputs'); + expect(Production::query()->count())->toBe(0); +}); + +test('outputs validieren produkt und stueckzahl im service', function () { + $user = ap28MakeUser(); + $location = Location::factory()->create(); + $product = ap28MakeSimpleProduct('AP28-Validierung'); + $set = ap28MakeProduct('AP28-Set', ['is_set' => true, 'no_recipe_required' => true]); + + $base = ['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-07-01', 'quantity' => 1, 'notes' => null]; + + expect(fn () => app(ProductionService::class)->store($base, [], $user->id, [ + ['product_id' => $set->id, 'quantity' => 5, 'batch_number' => null], + ]))->toThrow(ValidationException::class); + + expect(fn () => app(ProductionService::class)->store($base, [], $user->id, [ + ['product_id' => $product->id, 'quantity' => 0, 'batch_number' => null], + ]))->toThrow(ValidationException::class); +}); + +test('recipe-payload liefert batch_prefix, phasen und inci-zusatzspalten', function () { + $user = ap28MakeUser(); + $location = Location::factory()->create(); + + $ing = Ingredient::query()->create([ + 'name' => 'AP28-Payload-Rohstoff', + 'trans_name' => '', + 'inci' => 'AP28-Payload-Rohstoff', + 'trans_inci' => '', + 'effect' => '', + 'trans_effect' => '', + 'active' => true, + 'pos' => 0, + 'alt_name' => 'Phospholipon 80 H', + ]); + + $product = ap28MakeProduct('AP28-Payload-Produkt', ['batch_prefix' => 'TP1']); + + $phase = $product->recipePhases()->create([ + 'recipe_type' => 'manufacturer', + 'name' => 'Phase A', + 'note' => 'auf 70 °C erhitzen', + 'pos' => 0, + ]); + + ProductIngredient::query()->create([ + 'product_id' => $product->id, + 'ingredient_id' => $ing->id, + 'pos' => 0, + 'gram' => 100, + 'factor' => 1.0, + 'recipe_type' => 'manufacturer', + 'recipe_phase_id' => $phase->id, + ]); + + $payload = app(ProductionService::class)->buildRecipePayload($product->fresh(), $location->id, 1); + + expect($payload['product']['batch_prefix'])->toBe('TP1'); + expect($payload['phases'])->toHaveCount(1); + expect($payload['phases'][0]['name'])->toBe('Phase A'); + expect($payload['phases'][0]['note'])->toBe('auf 70 °C erhitzen'); + + $first = $payload['ingredients'][0]; + expect($first['alt_name'])->toBe('Phospholipon 80 H'); + expect($first['phase_id'])->toBe($phase->id); + expect(array_key_exists('storage_label', $first))->toBeTrue(); + expect(array_key_exists('quality', $first))->toBeTrue(); +}); + +test('produktions-maske rendert ergebnis-block und planungs-hinweis', function () { + $user = ap28MakeUser(); + + $this->actingAs($user, 'user'); + $response = $this->get(route('admin.inventory.productions.create')); + + $response->assertOk(); + $response->assertSee('Produktionsergebnis'); + $response->assertSee('Geplante Stückzahl (Planung)'); + $response->assertSee('Die Planung oben ist unverbindlich.', false); + $response->assertSee('output-row-template', false); +}); diff --git a/tests/Feature/StockEntryChargeMergeTest.php b/tests/Feature/StockEntryChargeMergeTest.php index a50d26c..c1a1d83 100644 --- a/tests/Feature/StockEntryChargeMergeTest.php +++ b/tests/Feature/StockEntryChargeMergeTest.php @@ -296,6 +296,9 @@ test('produktion per formular bucht charge fefo auf beide einkaeufe', function ( 'ingredient_lines' => [ ['ingredient_id' => $ing->id, 'batch_key' => 'b:AP27-CHARGE', 'quantity_used' => '150'], ], + 'outputs' => [ + ['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null], + ], ]); $response->assertSessionHasNoErrors();