$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, array $outputs = [], bool $draft = false): Production { return DB::transaction(function () use ($data, $ingredientLines, $userId, $outputs, $draft) { $product = Product::query() ->with(['manufacturer_ingredients', 'packagings']) ->findOrFail($data['product_id']); $locationId = (int) $data['location_id']; $producedQty = (int) $data['quantity']; if ($producedQty < 1) { throw ValidationException::withMessages(['quantity' => __('Die Stückzahl muss mindestens 1 sein.')]); } $this->assertNotASet($product); if ($draft) { // AP-43/F-03: Entwurf — keine Rezeptur-/Mengen-/Bestandsprüfung; alle // Zeilen mit Rohstoff + Charge werden roh gesichert (auch ohne Menge). $ingredientLines = $this->draftLines($ingredientLines); } elseif ($product->no_recipe_required) { $ingredientLines = []; } else { $this->assertManufacturerRecipe($product); $requiredGrams = $this->requiredGramsByIngredient($product, $producedQty); $this->assertLinesMatchRecipe($requiredGrams, $ingredientLines); foreach ($ingredientLines as $line) { $this->assertStockEntryMatchesLine($line, $locationId); } } $mhdWarning = $draft ? false : $this->computeMhdWarning($product, $data['produced_at'], $ingredientLines); $production = Production::query()->create([ 'product_id' => $product->id, 'location_id' => $locationId, 'produced_by' => $userId, 'produced_at' => $data['produced_at'], 'quantity' => $producedQty, 'notes' => $data['notes'] ?? null, 'mhd_warning' => $mhdWarning, 'status' => $draft ? Production::STATUS_DRAFT : Production::STATUS_COMPLETED, ]); foreach ($ingredientLines as $line) { $production->productionIngredients()->create($this->ingredientLineAttributes($line)); } // 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, $draft); } // 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); } 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, array $outputs = [], bool $draft = false): Production { 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. $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']) ->findOrFail($data['product_id']); $locationId = (int) $data['location_id']; $producedQty = (int) $data['quantity']; if ($producedQty < 1) { throw ValidationException::withMessages(['quantity' => __('Die Stückzahl muss mindestens 1 sein.')]); } $this->assertNotASet($product); if ($draft) { // AP-43/F-03: Entwurf — keine Rezeptur-/Mengen-/Bestandsprüfung, Zeilen roh sichern. $ingredientLines = $this->draftLines($ingredientLines); } elseif ($product->no_recipe_required) { $ingredientLines = []; } else { $this->assertManufacturerRecipe($product); $requiredGrams = $this->requiredGramsByIngredient($product, $producedQty); $this->assertLinesMatchRecipe($requiredGrams, $ingredientLines); foreach ($ingredientLines as $line) { $this->assertStockEntryMatchesLine($line, $locationId); } } $mhdWarning = $draft ? false : $this->computeMhdWarning($product, $data['produced_at'], $ingredientLines); $production->update([ 'product_id' => $product->id, 'location_id' => $locationId, 'produced_at' => $data['produced_at'], 'quantity' => $producedQty, 'notes' => $data['notes'] ?? null, 'mhd_warning' => $mhdWarning, 'status' => $draft ? Production::STATUS_DRAFT : Production::STATUS_COMPLETED, ]); $production->productionIngredients()->delete(); foreach ($ingredientLines as $line) { $production->productionIngredients()->create($this->ingredientLineAttributes($line)); } // F-05: Ergebnisdaten vor dem Neuaufbau festhalten — nur bei tatsächlicher // Änderung von Produkt/Menge wird der historische Verpackungs-Snapshot ersetzt. $hadSnapshot = ! $locked->isDraft(); $previousOutputs = $this->outputSignature($production); $production->outputs()->delete(); if (! $draft || $outputs !== []) { $this->persistOutputs($production, $product, $producedQty, $outputs, $draft); } if ($draft) { // AP-43: Entwürfe buchen keinen Bestand. Ein zuvor gelöschter Verpackungs- // Snapshot bleibt für den Entwurf leer. $production->productionPackagings()->delete(); } else { // AP-45: Verpackung folgt dem Produktionsergebnis, daher nach den Outputs. // F-05: Der Snapshot ist historisch (Konzept „Warum ein Snapshot?“): Eine // Notiz-/Datumskorrektur an einer alten Produktion darf ihn nicht aus der // heutigen Stückliste neu berechnen. Regel: Snapshot bleibt, solange // Ergebnisprodukte und Stückzahlen unverändert sind; bei einer echten // Mengen-/Produktkorrektur wird er mit der aktuellen Stückliste neu gebildet. if (! $hadSnapshot || $this->outputSignature($production) !== $previousOutputs) { $production->productionPackagings()->delete(); $this->syncPackagingSnapshot($production); } $production->setRelation('product', $product); $this->productStockService->recordProductionStock($production, $userId); } 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, bool $draft = false): 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.')]); } // F-03: Im Entwurf darf die Stückzahl noch fehlen (0); beim Abschluss ist sie Pflicht. if ($quantity < 1 && ! $draft) { throw ValidationException::withMessages(['outputs' => __('Die produzierte Stückzahl muss je Ergebniszeile mindestens 1 sein.')]); } $quantity = max(0, $quantity); $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. */ public function assertNotASet(Product $product): void { if ($product->is_set) { throw ValidationException::withMessages([ 'product_id' => __('Sets können nicht produziert werden. Bitte ein Einzelprodukt wählen.'), ]); } } /** * Produktion basiert ausschließlich auf der Hersteller-Rezeptur (kein Fallback auf die Produkt-Rezeptur). */ public function assertManufacturerRecipe(Product $product): void { $product->loadMissing('manufacturer_ingredients'); if ($product->manufacturer_ingredients->isEmpty()) { throw ValidationException::withMessages([ 'product_id' => __('Für dieses Produkt ist keine Hersteller-Rezeptur gepflegt. Eine Produktion ist erst möglich, wenn eine Hersteller-Rezeptur hinterlegt wurde.'), ]); } } /** * @param array $requiredGrams * @param array $ingredientLines */ private function assertLinesMatchRecipe(array $requiredGrams, array $ingredientLines): void { $sums = []; foreach ($ingredientLines as $line) { $iid = (int) $line['ingredient_id']; $used = $this->parseQuantity($line['quantity_used'] ?? null); $sums[$iid] = ($sums[$iid] ?? 0) + $used; } foreach ($requiredGrams as $iid => $req) { $sum = $sums[$iid] ?? 0; // AP-39 (Kundenentscheid 26.08.2026): Abwiegen muss nicht aufs Gramm // passen — gebucht wird der reale Ist-Wert. Toleranz ±5 % (mit kleinem // absolutem Sockel gegen Float-/Kleinmengen-Effekte); größere // Abweichungen fängt der Zahlendreher-Schutz weiterhin ab. if (! self::withinTolerance((float) $sum, (float) $req)) { throw ValidationException::withMessages([ 'ingredient_lines' => __('Die Ist-Menge je INCI darf höchstens 5 % vom Soll abweichen (INCI :id: Soll :req g, Ist :sum g).', [ 'id' => $iid, 'req' => formatWawiQuantity($req), 'sum' => formatWawiQuantity($sum), ]), ]); } } foreach ($sums as $iid => $_sum) { if (! isset($requiredGrams[$iid])) { throw ValidationException::withMessages([ 'ingredient_lines' => __('Unerwarteter Inhaltsstoff in den Chargen-Zeilen.'), ]); } } } /** * AP-45 (Kundenentscheid 26.08.2026): Verpackung (Etikett/Tiegel) wird aus dem * tatsächlichen Produktionsergebnis gebucht — je Ergebnisprodukt dessen eigene * Stückliste × Ergebnismenge, über alle Outputs summiert — nicht aus der * geplanten Stückzahl. Muss nach persistOutputs laufen. */ private function syncPackagingSnapshot(Production $production): void { $outputs = $production->outputs()->with('product.packagings')->get(); $piecesByPackaging = []; foreach ($outputs as $output) { $outputProduct = $output->product; if ($outputProduct === null) { continue; } foreach ($outputProduct->packagings as $bom) { $perUnit = (float) ($bom->pivot->quantity ?? 1); $piecesByPackaging[$bom->id] = ($piecesByPackaging[$bom->id] ?? 0) + $perUnit * (int) $output->quantity; } } foreach ($piecesByPackaging as $packagingItemId => $pieces) { $production->productionPackagings()->create([ 'packaging_item_id' => (int) $packagingItemId, 'quantity_used' => max(1, (int) round($pieces)), ]); } } /** * F-05: Vergleichbare Signatur des Produktionsergebnisses (Produkt + Stückzahl, * reihenfolgeunabhängig). * * @return array */ private function outputSignature(Production $production): array { // N-03 (Nachprüfung 08.09.2026): Stückzahlen je Produkt summieren — ein reiner // Chargensplit (6 + 4 statt 10) ist keine Mengenänderung. $totals = []; foreach ($production->outputs()->get(['product_id', 'quantity']) as $o) { $totals[(int) $o->product_id] = ($totals[(int) $o->product_id] ?? 0) + (int) $o->quantity; } ksort($totals); return array_map(fn ($pid, $qty) => $pid.':'.$qty, array_keys($totals), $totals); } /** * @return array */ public function requiredGramsByIngredient(Product $product, int $producedQuantity): array { $required = []; $scale = $this->recipeGramScale($product); foreach ($product->manufacturer_ingredients as $ing) { $gram = $ing->pivot->gram; if ($gram === null || $gram === '') { throw ValidationException::withMessages([ 'product_id' => __('Für „:name“ fehlt der Anteil (%) in der Hersteller-Rezeptur.', ['name' => $ing->name]), ]); } $factor = (float) ($ing->pivot->factor ?? 1.1); // Audit M-8: aufsummieren statt überschreiben — kommt derselbe Rohstoff // mehrfach vor (z. B. in zwei Phasen), zählt die Gesamtmenge. // AP-35: Anteil (%) × Füllgewicht-Skala × Overage-Faktor × Stückzahl. $required[(int) $ing->id] = ($required[(int) $ing->id] ?? 0.0) + (float) $gram * $scale * $factor * $producedQuantity; } return $required; } /** * AP-35 (Kundenentscheid 26.08.2026): Faktor, mit dem der Rezeptur-Anteil (%) * in Gramm je Stück umgerechnet wird = Füllgewicht / 100. Ohne bekannte * Massenbasis bleibt es beim Altverhalten (Anteil = Gramm), Faktor 1,0. */ public function recipeGramScale(Product $product): float { $baseMass = $this->productBaseMassPerPiece($product); return $baseMass !== null ? $baseMass / 100.0 : 1.0; } /** * F-14: Hat das Produkt eine Massenbasis (Füllgewicht bzw. ml-Füllmenge), sodass * Rezeptur-Prozente in Gramm je Stück umgerechnet werden können? */ public function hasMassBasis(Product $product): bool { return $this->productBaseMassPerPiece($product) !== null; } /** * Massenbasis (Gramm) je produziertem Stück: explizites Füllgewicht hat Vorrang; * sonst gilt bei ml-Produkten (unit = 1) die Füllmenge als Gramm (1 g/ml). * Stück-/Kapsel-Produkte (unit = 2) o. Ä. haben ohne Füllgewicht keine Basis. */ private function productBaseMassPerPiece(Product $product): ?float { if ($product->fill_weight !== null && (float) $product->fill_weight > 0) { return (float) $product->fill_weight; } if ((int) $product->unit === 1 && $product->contents_total !== null && (int) $product->contents_total > 0) { return (float) $product->contents_total; } return null; } /** * @param array{ingredient_id: int, stock_entry_id: int, quantity_used?: mixed} $line */ public function assertStockEntryMatchesLine(array $line, int $locationId): void { // Audit H-2: Service-seitiger Guard zusätzlich zur Request-Validierung. if ($this->parseQuantity($line['quantity_used'] ?? null) <= 0) { throw ValidationException::withMessages([ 'ingredient_lines' => __('Die Menge je Chargen-Zeile muss größer als 0 sein.'), ]); } $entry = StockEntry::query()->findOrFail((int) $line['stock_entry_id']); if ($entry->status !== 'received') { throw ValidationException::withMessages([ 'ingredient_lines' => __('Wareneingang :id ist nicht als eingegangen gebucht.', ['id' => $entry->id]), ]); } if ((int) $entry->ingredient_id !== (int) $line['ingredient_id']) { throw ValidationException::withMessages(['ingredient_lines' => __('Charge passt nicht zum Inhaltsstoff.')]); } if ((int) $entry->location_id !== $locationId) { throw ValidationException::withMessages(['ingredient_lines' => __('Charge gehört zu einem anderen Lagerort.')]); } } /** * @param array $ingredientLines */ public function computeMhdWarning(Product $product, string $producedAt, array $ingredientLines): bool { if ($product->shelf_life_type !== 'fixed' || ! $product->shelf_life_months) { return false; } $productEnd = Carbon::parse($producedAt)->addMonths((int) $product->shelf_life_months)->startOfDay(); foreach ($ingredientLines as $line) { $entry = StockEntry::query()->find((int) $line['stock_entry_id']); if (! $entry || ! $entry->best_before) { continue; } if ($entry->best_before->lt($productEnd)) { return true; } } return false; } /** * Verbrauchte Menge je Wareneingang: Produktionsverbrauch **plus** chargen- * bezogener Ausschuss (Audit H-1 — vorher zählte nur die Produktion, wodurch * entsorgte Chargen weiter als verfügbar galten). Optional ohne eine * bestimmte Produktion. * * @param array $stockEntryIds * @return array */ public function consumedByStockEntry(array $stockEntryIds, ?int $excludeProductionId = null): array { if ($stockEntryIds === []) { return []; } $rows = ProductionIngredient::query() ->join('productions', 'productions.id', '=', 'production_ingredients.production_id') ->whereNull('productions.deleted_at') // Prüfung 08.09.2026 F-01: Entwürfe sind nicht bestandswirksam — wie in // InventoryService::remainingByIngredient(), sonst blockiert ein Entwurf die Charge. ->where('productions.status', '!=', Production::STATUS_DRAFT) ->selectRaw('production_ingredients.stock_entry_id as stock_entry_id, SUM(production_ingredients.quantity_used) as used') ->whereIn('production_ingredients.stock_entry_id', $stockEntryIds) ->when($excludeProductionId !== null, fn ($q) => $q->where('production_ingredients.production_id', '!=', $excludeProductionId)) ->groupBy('production_ingredients.stock_entry_id') ->pluck('used', 'stock_entry_id'); $result = []; foreach ($rows as $stockEntryId => $used) { $result[(int) $stockEntryId] = (float) $used; } $disposed = StockDisposal::query() ->where('disposal_type', 'ingredient') ->whereNotNull('stock_entry_id') ->whereIn('stock_entry_id', $stockEntryIds) ->selectRaw('stock_entry_id, SUM(quantity) as used') ->groupBy('stock_entry_id') ->pluck('used', 'stock_entry_id'); foreach ($disposed as $stockEntryId => $used) { $result[(int) $stockEntryId] = ($result[(int) $stockEntryId] ?? 0.0) + (float) $used; } return $result; } /** * Wareneingänge eines Inhaltsstoffs am Standort mit Restbestand > 0 (FEFO-Reihenfolge). * * @return Collection */ public function availableStockEntriesForIngredient(int $ingredientId, int $locationId, ?int $excludeProductionId = null): Collection { $entries = StockEntry::query() ->with('supplier') ->where('status', 'received') ->where('entry_type', 'ingredient') ->where('ingredient_id', $ingredientId) ->where('location_id', $locationId) ->orderByRaw('best_before is null, best_before asc') ->orderBy('id') ->get(); $consumed = $this->consumedByStockEntry($entries->pluck('id')->all(), $excludeProductionId); $entries = $entries->map(function (StockEntry $entry) use ($consumed) { $received = $entry->received_quantity !== null ? (float) $entry->received_quantity : 0.0; $entry->setAttribute('remaining_quantity', roundWawiQuantity($received - ($consumed[(int) $entry->id] ?? 0.0))); return $entry; }); return $this->applyUnboundDisposals($entries, $ingredientId) ->filter(fn (StockEntry $entry) => (float) $entry->getAttribute('remaining_quantity') > 0.0) ->values(); } /** * N-05 (Nachprüfung 08.09.2026): Ausschuss **ohne** Chargenbezug (Altbestand vor F-09) * mindert Gesamt- und Standortbestand, aber keine einzelne Charge. Für die * Verfügbarkeit wird er hier je Standort FEFO auf die Chargen angerechnet — dieselbe * Regel, nach der neuer ungebundener Ausschuss gebucht wird. Es wird nichts * geschrieben; die Altbuchungen bleiben unverändert. * * @param Collection $entries FEFO-sortiert, mit Attribut remaining_quantity * @return Collection */ public function applyUnboundDisposals(Collection $entries, int $ingredientId): Collection { if ($entries->isEmpty()) { return $entries; } $unbound = StockDisposal::query() ->where('disposal_type', 'ingredient') ->where('ingredient_id', $ingredientId) ->whereNull('stock_entry_id') ->whereIn('location_id', $entries->pluck('location_id')->unique()->all()) ->selectRaw('location_id, SUM(quantity) as qty') ->groupBy('location_id') ->pluck('qty', 'location_id'); foreach ($unbound as $locationId => $qty) { $needed = roundWawiQuantity((float) $qty); foreach ($entries as $entry) { if ($needed <= 0) { break; } if ((int) $entry->location_id !== (int) $locationId) { continue; } $remaining = (float) $entry->getAttribute('remaining_quantity'); if ($remaining <= 0) { continue; } $take = roundWawiQuantity(min($remaining, $needed)); $entry->setAttribute('remaining_quantity', roundWawiQuantity($remaining - $take)); $needed = roundWawiQuantity($needed - $take); } } return $entries; } public function stockEntryLabel(StockEntry $entry): string { $parts = []; $parts[] = $entry->supplier?->name ?: __('Lieferant unbekannt'); $parts[] = $entry->batch_number ?: ('#'.$entry->id); if ($entry->best_before) { $parts[] = $entry->best_before->format('d.m.Y'); } return implode(' - ', $parts); } /** * AP-27: Verfügbare Chargen je batch_number zusammengeführt (FEFO-Reihenfolge bleibt erhalten). * Grundlage für das Charge-Dropdown der Produktion: eine Option je Charge mit Summen-Rest. * * @param Collection $entries FEFO-sortierte Einträge mit remaining_quantity * @return array */ public function batchOptionsForEntries(Collection $entries): array { return $entries ->groupBy(fn (StockEntry $entry) => $entry->batchGroupKey()) ->map(function ($group, string $key) { /** @var StockEntry $first */ $first = $group->first(); $suppliers = $group->map(fn (StockEntry $e) => $e->supplier?->name)->filter()->unique(); $bestBefore = $group->pluck('best_before')->filter()->min(); $labelParts = []; $labelParts[] = $suppliers->isNotEmpty() ? $suppliers->implode(', ') : __('Lieferant unbekannt'); $labelParts[] = $first->batch_number ?: ('#'.$first->id); if ($bestBefore) { $labelParts[] = $bestBefore->format('d.m.Y'); } return [ 'key' => $key, 'label' => implode(' - ', $labelParts), 'batch_number' => $first->batch_number, 'best_before' => $bestBefore?->format('Y-m-d'), 'remaining' => roundWawiQuantity($group->sum(fn (StockEntry $e) => (float) $e->getAttribute('remaining_quantity'))), 'purchases' => $group->count(), ]; }) ->values() ->all(); } /** * AP-27: Löst Chargen-Zeilen (batch_key) in Einzel-Wareneingänge auf. * Der Verbrauch wird FEFO auf die Einträge der Charge verteilt; ein etwaiger * Überhang wird dem letzten Eintrag zugerechnet (kein Restbestands-Hardstop, * konsistent zum bisherigen Verhalten je Einzel-Eintrag). * Zeilen im alten Format (stock_entry_id) werden unverändert durchgereicht. * * @param array $ingredientLines * @return array */ public function expandBatchLines(array $ingredientLines, int $locationId, ?int $excludeProductionId = null): array { $expanded = []; $entriesByIngredient = []; foreach ($ingredientLines as $line) { $batchKey = trim((string) ($line['batch_key'] ?? '')); if ($batchKey === '') { $expanded[] = $line; continue; } $ingredientId = (int) $line['ingredient_id']; $entriesByIngredient[$ingredientId] ??= $this->availableStockEntriesForIngredient($ingredientId, $locationId, $excludeProductionId); $batchEntries = $entriesByIngredient[$ingredientId] ->filter(fn (StockEntry $entry) => $entry->batchGroupKey() === $batchKey) ->values(); if ($batchEntries->isEmpty()) { throw ValidationException::withMessages([ 'ingredient_lines' => __('Charge :key ist am gewählten Lagerort nicht (mehr) verfügbar.', [ 'key' => explode(':', $batchKey, 3)[2] ?? $batchKey, ]), ]); } $needed = roundWawiQuantity($this->parseQuantity($line['quantity_used'] ?? null)); $lastIndex = $batchEntries->count() - 1; foreach ($batchEntries as $index => $entry) { $capacity = (float) $entry->getAttribute('remaining_quantity'); $take = $index === $lastIndex ? $needed : roundWawiQuantity(min($needed, $capacity)); if ($take > 0) { $expanded[] = [ 'ingredient_id' => $ingredientId, 'stock_entry_id' => (int) $entry->id, 'quantity_used' => $take, // F-04: Phase der Formularzeile für die spätere Vorbelegung mitführen. 'recipe_phase_id' => ! empty($line['recipe_phase_id']) ? (int) $line['recipe_phase_id'] : null, ]; // Audit H-3: Restmenge im Cache dekrementieren — sonst rechnet // eine zweite Zeile derselben Charge erneut gegen die volle // Kapazität und belastet denselben Einkauf doppelt. $entry->setAttribute('remaining_quantity', roundWawiQuantity($capacity - $take)); } $needed = roundWawiQuantity($needed - $take); if ($needed <= 0) { break; } } } return $expanded; } /** * @return array */ public function buildRecipePayload(Product $product, int $locationId, int $productionQuantity, ?int $excludeProductionId = null): array { $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; $hasRecipe = $product->manufacturer_ingredients->isNotEmpty(); // AP-35: Umrechnungsfaktor Anteil (%) → Gramm je Stück (Füllgewicht / 100). $scale = $this->recipeGramScale($product); // AP-40: aktueller Rohstoffbestand je INCI (für Bestands-/Differenz-Spalte). // InventoryService wird lazy aufgelöst — es hängt selbst von ProductionService // ab, eine Konstruktor-Injektion ergäbe eine zirkuläre Abhängigkeit. $ingredientIds = $product->manufacturer_ingredients->pluck('id')->map(fn ($id) => (int) $id)->all(); $stockByIngredient = app(InventoryService::class)->remainingByIngredient($ingredientIds); $ingredients = []; foreach ($product->manufacturer_ingredients as $ing) { $gram = $ing->pivot->gram; $factor = (float) ($ing->pivot->factor ?? 1.1); $hasGram = $gram !== null && $gram !== ''; $req = $hasGram ? (float) $gram * $scale * $factor * $qty : null; $available = $this->availableStockEntriesForIngredient((int) $ing->id, $locationId, $excludeProductionId); $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, // AP-40: aktueller Bestand des Rohstoffs (global, in Gramm). 'current_stock' => roundWawiQuantity((float) ($stockByIngredient[(int) $ing->id] ?? 0.0)), 'stock_entries' => $available ->map(function (StockEntry $se) { return [ 'id' => $se->id, 'label' => $this->stockEntryLabel($se), 'batch_number' => $se->batch_number, 'best_before' => $se->best_before?->format('Y-m-d'), 'remaining' => (float) $se->getAttribute('remaining_quantity'), ]; })->values()->all(), // AP-27: Dropdown-Optionen je Charge zusammengeführt (Summen-Rest, FEFO-Reihenfolge). 'batches' => $this->batchOptionsForEntries($available), ]; } $packagings = []; foreach ($product->packagings as $pk) { $perUnit = (float) ($pk->pivot->quantity ?? 1); $packagings[] = [ 'id' => $pk->id, 'name' => $pk->name, 'quantity_per_product' => $perUnit, 'total_pieces' => (int) round($perUnit * $qty), 'weight_grams' => $pk->weight_grams !== null ? (float) $pk->weight_grams : null, 'material_name' => $pk->packagingMaterial?->name, ]; } return [ 'product' => [ 'id' => $product->id, '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-35: Skala Anteil (%) → Gramm/Stück, damit die Maske dieselbe // Sollmenge rechnet wie der Server (Füllgewicht / 100; 1,0 ohne Basis). 'recipe_gram_scale' => $scale, // 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, ]; } private function parseQuantity(mixed $value): float { if ($value === null || $value === '') { return 0.0; } // Audit N-7: Zahlen (interne Aufrufer) direkt; Strings konsequent im // deutschen Format parsen — vorher wich „1.500" (Server: 1,5) von der // UI-Ampel (1500) ab. // Prüfung AP-53 (4.2): kanonische Menge — immer auf drei Stellen gerundet, // egal ob die Zeile per batch_key oder stock_entry_id kommt. if (is_int($value) || is_float($value)) { return roundWawiQuantity($value); } $n = reFormatWawiNumber((string) $value); return $n !== null && $n !== '' && is_numeric($n) ? roundWawiQuantity($n) : 0.0; } /** * AP-43/F-03: Entwurfszeilen verlustfrei sichern — jede Zeile mit Rohstoff und * Charge (batch_key) bzw. Wareneingang bleibt erhalten, auch ohne Menge. * Die Auflösung auf Wareneingänge und die fachliche Prüfung folgen beim Abschluss. * * @param array> $ingredientLines * @return array> */ private function draftLines(array $ingredientLines): array { return array_values(array_filter($ingredientLines, function (array $line): bool { return (int) ($line['ingredient_id'] ?? 0) > 0 && ((int) ($line['stock_entry_id'] ?? 0) > 0 || trim((string) ($line['batch_key'] ?? '')) !== '' // N-01: Ist-Menge ohne Charge ebenfalls sichern. || $this->parseQuantity($line['quantity_used'] ?? null) > 0); })); } /** * @param array $line * @return array */ private function ingredientLineAttributes(array $line): array { $stockEntryId = (int) ($line['stock_entry_id'] ?? 0); $batchKey = trim((string) ($line['batch_key'] ?? '')); return [ 'ingredient_id' => (int) $line['ingredient_id'], 'stock_entry_id' => $stockEntryId > 0 ? $stockEntryId : null, 'batch_key' => $stockEntryId > 0 ? null : ($batchKey !== '' ? $batchKey : null), 'recipe_phase_id' => ! empty($line['recipe_phase_id']) ? (int) $line['recipe_phase_id'] : null, 'quantity_used' => $this->parseQuantity($line['quantity_used'] ?? null), ]; } }