$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(['p_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.')]); } if ($product->p_ingredients->isEmpty()) { throw ValidationException::withMessages(['product_id' => __('Das Produkt hat keine Rezeptur (Inhaltsstoffe).')]); } $requiredGrams = $this->requiredGramsByIngredient($product, $producedQty); $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.'), ]); } } 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), ]); } 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 $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) { $product = Product::query() ->with(['p_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.')]); } if ($product->p_ingredients->isEmpty()) { throw ValidationException::withMessages(['product_id' => __('Das Produkt hat keine Rezeptur (Inhaltsstoffe).')]); } $requiredGrams = $this->requiredGramsByIngredient($product, $producedQty); $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 ($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(); 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 $production->fresh(['product', 'location', 'productionIngredients', 'productionPackagings']); }); } /** * @return array */ public function requiredGramsByIngredient(Product $product, int $producedQuantity): array { $required = []; foreach ($product->p_ingredients as $ing) { $gram = $ing->pivot->gram; if ($gram === null || $gram === '') { throw ValidationException::withMessages([ 'product_id' => __('Für „:name“ fehlt Gramm in der 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; } /** * Letzte empfangene Wareneingänge pro Inhaltsstoff am Standort (max. 3). * * @return Collection */ public function latestStockEntriesForIngredient(int $ingredientId, int $locationId, int $limit = 3) { return StockEntry::query() ->where('status', 'received') ->where('entry_type', 'ingredient') ->where('ingredient_id', $ingredientId) ->where('location_id', $locationId) ->orderByDesc('received_at') ->orderByDesc('id') ->limit($limit) ->get(); } /** * @return array */ public function buildRecipePayload(Product $product, int $locationId, int $productionQuantity): array { $product->loadMissing(['p_ingredients', 'packagings.packagingMaterial']); $qty = max(1, $productionQuantity); $ingredients = []; foreach ($product->p_ingredients as $ing) { $gram = $ing->pivot->gram; $factor = (float) ($ing->pivot->factor ?? 1.1); $req = ($gram !== null && $gram !== '') ? (float) $gram * $factor * $qty : null; $ingredients[] = [ 'id' => $ing->id, 'name' => $ing->name, 'gram' => $gram !== null && $gram !== '' ? (float) $gram : null, 'factor' => $factor, 'required_grams_total' => $req, 'stock_entries' => $this->latestStockEntriesForIngredient((int) $ing->id, $locationId)->map(function (StockEntry $se) { return [ 'id' => $se->id, 'batch_number' => $se->batch_number, 'best_before' => $se->best_before?->format('Y-m-d'), 'received_at' => $se->received_at?->format('Y-m-d'), 'received_quantity' => $se->received_quantity !== null ? (float) $se->received_quantity : null, ]; })->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 * max(1, $productionQuantity)), '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, ], 'location_id' => $locationId, 'production_quantity' => $productionQuantity, '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; } }