$data * @param array $ingredientLines */ public function store(array $data, array $ingredientLines, int $userId): Production { return DB::transaction(function () use ($data, $ingredientLines, $userId) { $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); $production->setRelation('product', $product); $this->productStockService->recordProductionStock($production, $userId); return $production->fresh(['product', 'location', 'productionIngredients', 'productionPackagings']); }); } /** * @param array $data * @param array $ingredientLines */ public function updateProduction(Production $production, array $data, array $ingredientLines, int $userId): Production { return DB::transaction(function () use ($production, $data, $ingredientLines, $userId) { $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->setRelation('product', $product); $this->productStockService->recordProductionStock($production, $userId); return $production->fresh(['product', 'location', 'productionIngredients', 'productionPackagings']); }); } /** * 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); $required[(int) $ing->id] = (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 { $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 (über alle Produktionen), 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() ->selectRaw('stock_entry_id, SUM(quantity_used) as used') ->whereIn('stock_entry_id', $stockEntryIds) ->when($excludeProductionId !== null, fn ($q) => $q->where('production_id', '!=', $excludeProductionId)) ->groupBy('stock_entry_id') ->pluck('used', 'stock_entry_id'); $result = []; foreach ($rows as $stockEntryId => $used) { $result[(int) $stockEntryId] = (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); } /** * @return array */ public function buildRecipePayload(Product $product, int $locationId, int $productionQuantity, ?int $excludeProductionId = null): array { $product->loadMissing(['manufacturer_ingredients', 'packagings.packagingMaterial']); $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; $ingredients[] = [ 'id' => $ing->id, 'name' => $ing->name, 'gram' => $hasGram ? (float) $gram : null, 'factor' => $factor, 'required_grams_total' => $req, 'stock_entries' => $this->availableStockEntriesForIngredient((int) $ing->id, $locationId, $excludeProductionId) ->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(), ]; } $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, ], 'recipe_required' => $recipeRequired, 'has_recipe' => $hasRecipe, 'location_id' => $locationId, 'production_quantity' => $qty, 'ingredients' => $ingredients, 'packagings' => $packagings, ]; } private function parseQuantity(mixed $value): float { if ($value === null || $value === '') { return 0.0; } if (is_numeric($value)) { return (float) $value; } $n = reFormatNumber((string) $value); return $n !== null && $n !== '' ? (float) $n : 0.0; } }