diff --git a/app/Http/Controllers/Admin/Inventory/RawMaterialStockController.php b/app/Http/Controllers/Admin/Inventory/RawMaterialStockController.php index 6d9ab50..c4d6a56 100644 --- a/app/Http/Controllers/Admin/Inventory/RawMaterialStockController.php +++ b/app/Http/Controllers/Admin/Inventory/RawMaterialStockController.php @@ -5,9 +5,12 @@ namespace App\Http\Controllers\Admin\Inventory; use App\Http\Controllers\Controller; use App\Models\Ingredient; use App\Models\Location; +use App\Models\Product; +use App\Models\ProductIngredient; use App\Models\TaxRate; use App\Repositories\StockEntryRepository; use App\Services\InventoryService; +use App\Services\ProductionService; use App\Services\ProductStockService; use Illuminate\Http\Request; use Illuminate\View\View; @@ -18,6 +21,7 @@ class RawMaterialStockController extends Controller protected InventoryService $inventoryService, protected ProductStockService $productStockService, protected StockEntryRepository $stockEntryRepository, + protected ProductionService $productionService, ) {} public function index(Request $request): View @@ -90,11 +94,12 @@ class RawMaterialStockController extends Controller 'products' => fn ($q) => $q->where('active', true)->where('production_relevant', true), ]); - // AP-37: Jedes Produkt hat i. d. R. zwei product_ingredients-Zeilen - // (recipe_type „manufacturer" + „product"), wodurch die belongsToMany- - // Relation dasselbe Produkt doppelt liefert. Für die „Enthalten in"-Anzeige - // (und den darauf basierenden Produktbestand) nach Produkt-ID deduplizieren. - $ingredient->setRelation('products', $ingredient->products->unique('id')->values()); + // AP-37/Prüfung 08.09.2026 F-14: „Enthalten in" aus der Hersteller-Rezeptur — + // alle Zeilen (Phasen) je Produkt summieren; Anteil (%) und Verbrauch je Stück + // (Anteil × Füllgewicht-Skala × Faktor, wie in der Produktion) statt roher Pivot-Wert. + $recipeUsage = $this->recipeUsageByProduct($ingredient); + $ingredient->setRelation('products', $ingredient->products->unique('id') + ->filter(fn (Product $p) => isset($recipeUsage[$p->id]))->values()); // AP-27: Chargen je batch_number zusammengeführt (jeder Einkauf bleibt eigener stock_entry). $chargeGroups = $this->inventoryService->chargeGroupsForIngredient($ingredient->id); @@ -133,10 +138,45 @@ class RawMaterialStockController extends Controller 'expectedEmpty' => $this->inventoryService->expectedEmptyDate($remaining, $daily), 'status' => $this->inventoryService->stockStatus($minAlert, $remaining, $daily, $leadDays, $openTotal > 0), 'lastPriceBySupplier' => $lastPriceBySupplier, + 'recipeUsage' => $recipeUsage, 'taxRates' => TaxRate::query()->active()->orderBy('pos')->orderBy('name')->get(), ]); } + /** + * F-14: Anteil und Verbrauch je Stück aus der Hersteller-Rezeptur je Produkt + * (nur aktive, produktionsrelevante Produkte — AP-47). + * + * @return array + */ + private function recipeUsageByProduct(Ingredient $ingredient): array + { + $rows = ProductIngredient::query() + ->with('product') + ->where('ingredient_id', $ingredient->id) + ->where('recipe_type', 'manufacturer') + ->get() + ->filter(fn (ProductIngredient $row) => $row->product !== null + && (bool) $row->product->active && (bool) $row->product->production_relevant); + + $usage = []; + foreach ($rows->groupBy('product_id') as $productId => $productRows) { + $product = $productRows->first()->product; + $percent = (float) $productRows->sum(fn (ProductIngredient $r) => (float) ($r->gram ?? 0)); + $withFactor = (float) $productRows->sum(fn (ProductIngredient $r) => (float) ($r->gram ?? 0) * (float) ($r->factor ?? 1.1)); + $scale = $this->productionService->recipeGramScale($product); + $hasMassBasis = $this->productionService->hasMassBasis($product); + + $usage[(int) $productId] = [ + 'percent' => round($percent, 3), + 'grams_per_piece' => $hasMassBasis ? round($percent * $scale, 2) : null, + 'consumption_per_piece' => $hasMassBasis ? round($withFactor * $scale, 2) : null, + ]; + } + + return $usage; + } + /** * @return array [days => label] */ diff --git a/app/Services/ProductionService.php b/app/Services/ProductionService.php index 3def773..81c244a 100644 --- a/app/Services/ProductionService.php +++ b/app/Services/ProductionService.php @@ -395,6 +395,15 @@ class ProductionService 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). diff --git a/resources/views/admin/inventory/raw-material-stock/show.blade.php b/resources/views/admin/inventory/raw-material-stock/show.blade.php index 85cfe2d..cc95a23 100644 --- a/resources/views/admin/inventory/raw-material-stock/show.blade.php +++ b/resources/views/admin/inventory/raw-material-stock/show.blade.php @@ -95,25 +95,35 @@ {{ __('Produkt') }} {{ __('Produktbestand') }} - {{ __('Rezeptur-Anteil (g/Stück)') }} + {{ __('Anteil (%)') }} + {{ __('Verbrauch (g/Stück)') }} @forelse($ingredient->products as $product) + @php $usage = $recipeUsage[$product->id] ?? null; @endphp {{ $product->name }} {{ \App\Services\Util::formatNumber($productStock[$product->id] ?? 0, 0) }} {{ __('Stück') }} - @if($product->pivot->gram !== null && $product->pivot->gram !== '') - {{ \App\Services\Util::formatNumber($product->pivot->gram, 2) }} g + @if($usage) + {{ \App\Services\Util::formatNumber($usage['percent'], 2) }} % @else — @endif + + @if($usage && $usage['consumption_per_piece'] !== null) + {{ \App\Services\Util::formatNumber($usage['consumption_per_piece'], 2) }} g + ({{ __('netto') }} {{ \App\Services\Util::formatNumber($usage['grams_per_piece'], 2) }} g) + @else + — + @endif + @empty -
{{ __('Dieser Rohstoff wird in keiner aktiven Rezeptur verwendet.') }}
+
{{ __('Dieser Rohstoff wird in keiner aktiven Rezeptur verwendet.') }}
@endforelse diff --git a/tests/Feature/RawMaterialStockTest.php b/tests/Feature/RawMaterialStockTest.php index c798f62..6d483b3 100644 --- a/tests/Feature/RawMaterialStockTest.php +++ b/tests/Feature/RawMaterialStockTest.php @@ -495,3 +495,27 @@ test('F-11: admins sehen den letzten einkaufspreis weiterhin', function () { ->assertSuccessful() ->assertSee('123,45'); }); + +test('F-14: enthalten-in zeigt anteil in prozent und verbrauch je stück inkl. faktor, phasen summiert', function () { + $user = ap10MakeUser(1); + $ing = ap10MakeIngredient('F14-Anteil'); + + // 50-g-Produkt, 20 % mit Faktor 1,1 → netto 10 g, Verbrauch 11 g je Stück. + $single = Product::query()->create(['name' => 'F14-Einfach', 'title' => 'x', 'active' => true, 'production_relevant' => true, 'fill_weight' => 50]); + ProductIngredient::query()->create(['product_id' => $single->id, 'ingredient_id' => $ing->id, 'pos' => 0, 'gram' => 20, 'factor' => 1.1, 'recipe_type' => 'manufacturer']); + // INCI-Zeile desselben Produkts darf nicht mitzählen. + ProductIngredient::query()->create(['product_id' => $single->id, 'ingredient_id' => $ing->id, 'pos' => 0, 'gram' => 99, 'factor' => 1.0, 'recipe_type' => 'product']); + + // Zwei Phasen 6 % + 4 % bei 100 g → 10 %, Verbrauch 10 g (Faktor 1,0). + $phased = Product::query()->create(['name' => 'F14-Phasen', 'title' => 'x', 'active' => true, 'production_relevant' => true, 'fill_weight' => 100]); + ProductIngredient::query()->create(['product_id' => $phased->id, 'ingredient_id' => $ing->id, 'pos' => 0, 'gram' => 6, 'factor' => 1.0, 'recipe_type' => 'manufacturer']); + ProductIngredient::query()->create(['product_id' => $phased->id, 'ingredient_id' => $ing->id, 'pos' => 1, 'gram' => 4, 'factor' => 1.0, 'recipe_type' => 'manufacturer']); + + $this->actingAs($user, 'user'); + $html = $this->get(route('admin.inventory.raw-material-stock.show', $ing))->assertSuccessful()->getContent(); + + expect($html)->toContain('20,00 %')->toContain('11,00 g')->toContain('netto 10,00 g') + ->toContain('10,00 %')->toContain('10,00 g') + ->not->toContain('99,00'); + expect(substr_count($html, 'F14-Einfach'))->toBe(1); +});