$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 = []): Production { return DB::transaction(function () use ($data, $ingredientLines, $userId, $outputs) { $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 ($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 = $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, ]); foreach ($ingredientLines as $line) { $production->productionIngredients()->create([ 'ingredient_id' => (int) $line['ingredient_id'], 'stock_entry_id' => (int) $line['stock_entry_id'], 'quantity_used' => $this->parseQuantity($line['quantity_used'] ?? null), ]); } $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', '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 = []): Production { return DB::transaction(function () use ($production, $data, $ingredientLines, $userId, $outputs) { // Audit M-9: Produktionszeile sperren — parallele Updates derselben // Produktion lesen sonst denselben Buchungs-Snapshot und buchen doppelt. Production::query()->lockForUpdate()->findOrFail($production->id); $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 ($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 = $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, ]); $production->productionIngredients()->delete(); foreach ($ingredientLines as $line) { $production->productionIngredients()->create([ 'ingredient_id' => (int) $line['ingredient_id'], 'stock_entry_id' => (int) $line['stock_entry_id'], 'quantity_used' => $this->parseQuantity($line['quantity_used'] ?? null), ]); } $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', '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. */ 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; if (abs($sum - $req) > 0.02) { throw ValidationException::withMessages([ 'ingredient_lines' => __('Summe der Chargen-Mengen pro INCI muss dem Soll-Verbrauch entsprechen (INCI :id: Soll :req g, Ist :sum g).', [ 'id' => $iid, 'req' => number_format($req, 2, ',', '.'), 'sum' => number_format($sum, 2, ',', '.'), ]), ]); } } foreach ($sums as $iid => $_sum) { if (! isset($requiredGrams[$iid])) { throw ValidationException::withMessages([ 'ingredient_lines' => __('Unerwarteter Inhaltsstoff in den Chargen-Zeilen.'), ]); } } } private function syncPackagingSnapshot(Production $production, Product $product, int $producedQty): void { foreach ($product->packagings as $bom) { $perUnit = (float) ($bom->pivot->quantity ?? 1); $pieces = (int) round($perUnit * $producedQty); if ($pieces < 1) { $pieces = 1; } $production->productionPackagings()->create([ 'packaging_item_id' => $bom->id, 'quantity_used' => $pieces, ]); } } /** * @return array */ public function requiredGramsByIngredient(Product $product, int $producedQuantity): array { $required = []; 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. $required[(int) $ing->id] = ($required[(int) $ing->id] ?? 0.0) + (float) $gram * $factor * $producedQuantity; } return $required; } /** * @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') ->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); return $entries ->map(function (StockEntry $entry) use ($consumed) { $received = $entry->received_quantity !== null ? (float) $entry->received_quantity : 0.0; $entry->setAttribute('remaining_quantity', round($received - ($consumed[(int) $entry->id] ?? 0.0), 2)); return $entry; }) ->filter(fn (StockEntry $entry) => (float) $entry->getAttribute('remaining_quantity') > 0.0) ->values(); } 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' => round($group->sum(fn (StockEntry $e) => (float) $e->getAttribute('remaining_quantity')), 2), '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 = round($this->parseQuantity($line['quantity_used'] ?? null), 2); $lastIndex = $batchEntries->count() - 1; foreach ($batchEntries as $index => $entry) { $capacity = (float) $entry->getAttribute('remaining_quantity'); $take = $index === $lastIndex ? $needed : round(min($needed, $capacity), 2); if ($take > 0) { $expanded[] = [ 'ingredient_id' => $ingredientId, 'stock_entry_id' => (int) $entry->id, 'quantity_used' => $take, ]; // 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', round($capacity - $take, 2)); } $needed = round($needed - $take, 2); 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(); $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 * $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, '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-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. if (is_int($value) || is_float($value)) { return (float) $value; } $n = reFormatWawiNumber((string) $value); return $n !== null && $n !== '' && is_numeric($n) ? (float) $n : 0.0; } }