diff --git a/app/Models/Product.php b/app/Models/Product.php index 3fa339d..95b96dc 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -194,6 +194,7 @@ class Product extends Model 'show_on' => 'array', 'action' => 'array', 'wp_number' => 'int', + 'fill_weight' => 'int', 'single_commission' => 'bool', 'amount_commission' => 'bool', 'exclude_stats_sales' => 'bool', @@ -233,6 +234,7 @@ class Product extends Model 'weight', 'contents', 'contents_total', + 'fill_weight', 'unit', 'number', 'wp_number', diff --git a/app/Services/ProductionService.php b/app/Services/ProductionService.php index cd32560..532c179 100644 --- a/app/Services/ProductionService.php +++ b/app/Services/ProductionService.php @@ -246,9 +246,14 @@ class ProductionService foreach ($requiredGrams as $iid => $req) { $sum = $sums[$iid] ?? 0; - if (abs($sum - $req) > 0.02) { + // 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. + $tolerance = max((float) $req * 0.05, 0.05); + if (abs($sum - $req) > $tolerance) { throw ValidationException::withMessages([ - 'ingredient_lines' => __('Summe der Chargen-Mengen pro INCI muss dem Soll-Verbrauch entsprechen (INCI :id: Soll :req g, Ist :sum g).', [ + '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' => number_format($req, 2, ',', '.'), 'sum' => number_format($sum, 2, ',', '.'), @@ -287,6 +292,7 @@ class ProductionService 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 === '') { @@ -297,12 +303,42 @@ class ProductionService $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; + // 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; + } + + /** + * 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 */ @@ -553,13 +589,15 @@ class ProductionService $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); $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; + $req = $hasGram ? (float) $gram * $scale * $factor * $qty : null; $available = $this->availableStockEntriesForIngredient((int) $ing->id, $locationId, $excludeProductionId); $ingredients[] = [ 'id' => $ing->id, @@ -612,6 +650,9 @@ class ProductionService '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) => [ diff --git a/database/migrations/2026_08_26_120000_add_fill_weight_to_products_table.php b/database/migrations/2026_08_26_120000_add_fill_weight_to_products_table.php new file mode 100644 index 0000000..f7c9962 --- /dev/null +++ b/database/migrations/2026_08_26_120000_add_fill_weight_to_products_table.php @@ -0,0 +1,28 @@ +integer('fill_weight')->nullable()->after('contents_total'); + }); + } + + public function down(): void + { + Schema::table('products', function (Blueprint $table) { + $table->dropColumn('fill_weight'); + }); + } +}; diff --git a/resources/views/admin/inventory/productions/_scripts.blade.php b/resources/views/admin/inventory/productions/_scripts.blade.php index 29516ad..f97ce01 100644 --- a/resources/views/admin/inventory/productions/_scripts.blade.php +++ b/resources/views/admin/inventory/productions/_scripts.blade.php @@ -6,6 +6,8 @@ var lineIndex = 0; var recipeIngredients = []; + // AP-35: Umrechnung Anteil (%) → Gramm/Stück (Füllgewicht / 100; 1,0 ohne Basis). + var recipeGramScale = 1; var loadedKey = null; function currentKey() { @@ -92,14 +94,25 @@ return; } var qty = parseInt($('#quantity').val(), 10) || 1; - var soll = ing.gram * (ing.factor || 1) * qty; + var soll = ing.gram * recipeGramScale * (ing.factor || 1) * qty; var sum = 0; + var anyEntered = false; $wrap.find('.recipe-qty-input').each(function () { + if (String($(this).val()).trim() !== '') { + anyEntered = true; + } sum += parseGerman($(this).val()); }); - var ok = Math.abs(sum - soll) <= 0.02; - $dot.css('color', ok ? '#28a745' : '#fd7e14') - .attr('title', ok ? '{{ __('Soll erfüllt') }}' : '{{ __('Summe weicht vom Soll ab') }}'); + // AP-39: Orange = nichts eingetragen; Grün = eingetragen und ±5 % vom Soll; + // Rot = eingetragen, aber > 5 % daneben (Zahlendreher-Schutz). + var tolerance = Math.max(soll * 0.05, 0.05); + if (!anyEntered) { + $dot.css('color', '#fd7e14').attr('title', '{{ __('Noch nichts eingetragen') }}'); + } else if (Math.abs(sum - soll) <= tolerance) { + $dot.css('color', '#28a745').attr('title', '{{ __('Soll erfüllt (±5 %)') }}'); + } else { + $dot.css('color', '#dc3545').attr('title', '{{ __('Abweichung über 5 % – bitte prüfen') }}'); + } } function updateAllStatuses() { @@ -116,7 +129,7 @@ if (!ing || ing.gram == null) { return; } - var soll = ing.gram * (ing.factor || 1) * qty; + var soll = ing.gram * recipeGramScale * (ing.factor || 1) * qty; $wrap.find('.recipe-soll').text(fmt(soll)); }); renderInciTable(); @@ -135,7 +148,7 @@ var totalPercent = 0; var totalGrams = 0; recipeIngredients.forEach(function (ing) { - var grams = ing.gram != null ? ing.gram * (ing.factor || 1) * qty : null; + var grams = ing.gram != null ? ing.gram * recipeGramScale * (ing.factor || 1) * qty : null; if (ing.gram != null) { totalPercent += ing.gram; } @@ -189,7 +202,7 @@ function renderIngredientWrap(ing, $container) { var qty = parseInt($('#quantity').val(), 10) || 1; - var soll = ing.gram * (ing.factor || 1) * qty; + var soll = ing.gram * recipeGramScale * (ing.factor || 1) * qty; var $wrap = $('
'); $wrap.data('recipe-ing', ing); var $head = $('
'); @@ -218,6 +231,7 @@ function renderRecipe(data) { recipeIngredients = data.ingredients || []; + recipeGramScale = data.recipe_gram_scale != null ? data.recipe_gram_scale : 1; $('#recipe-ingredient-lines').empty(); lineIndex = 0; diff --git a/resources/views/admin/product/form.blade.php b/resources/views/admin/product/form.blade.php index 4ae7509..ffa0d21 100755 --- a/resources/views/admin/product/form.blade.php +++ b/resources/views/admin/product/form.blade.php @@ -530,6 +530,11 @@ {{ Form::text('contents_total', $product->contents_total, ['placeholder' => __('Bsp: 150'), 'class' => 'form-control', 'id' => 'contents_total']) }} +
+ + {{ Form::text('fill_weight', $product->fill_weight, ['placeholder' => __('leer = Menge = Gramm (1 g/ml)'), 'class' => 'form-control', 'id' => 'fill_weight']) }} + {{ __('Basis für die Rezeptur-Mengenberechnung (Prozent × Füllgewicht).') }} +
{{ Form::select('unit', $product->unitTypes, $product->unit, ['data-live-search' => 'false', 'class' => 'selectpicker', 'id' => 'unit']) }} diff --git a/tests/Feature/ProductionScalingTest.php b/tests/Feature/ProductionScalingTest.php new file mode 100644 index 0000000..3c783cd --- /dev/null +++ b/tests/Feature/ProductionScalingTest.php @@ -0,0 +1,137 @@ +create([ + 'email' => uniqid('ps_', true).'@test.example', + 'password' => bcrypt('password'), + ]); + $user->forceFill(['admin' => 7, 'confirmed' => true, 'active' => true, 'wizard' => 100, 'blocked' => false])->save(); + + return $user->fresh(); +} + +function psIngredient(string $name): Ingredient +{ + return Ingredient::query()->create([ + 'name' => $name, 'trans_name' => '', 'inci' => $name, 'trans_inci' => '', + 'effect' => '', 'trans_effect' => '', 'active' => true, 'pos' => 0, + ]); +} + +/** + * @param array $attrs + */ +function psProduct(array $attrs, Ingredient $ing, float $gram = 100, float $factor = 1.0): Product +{ + $product = Product::query()->create(array_merge(['name' => uniqid('PS-', true), 'title' => 'PS', 'active' => true], $attrs)); + ProductIngredient::create([ + 'product_id' => $product->id, 'ingredient_id' => $ing->id, + 'pos' => 0, 'gram' => $gram, 'factor' => $factor, 'recipe_type' => 'manufacturer', + ]); + + return $product->fresh('manufacturer_ingredients'); +} + +test('AP-35: 100%-rezeptur skaliert auf die füllmenge eines ml-produkts', function () { + $ing = psIngredient('PS-Sheabutter'); + // 50 ml (unit=1) => 50 g Basis, 100 % Anteil, Faktor 1 => 50 g je Stück. + $product = psProduct(['contents_total' => 50, 'unit' => 1], $ing); + + $required = app(ProductionService::class)->requiredGramsByIngredient($product, 1); + expect($required[$ing->id])->toBe(50.0); + + // 3 Stück => 150 g + $required3 = app(ProductionService::class)->requiredGramsByIngredient($product, 3); + expect($required3[$ing->id])->toBe(150.0); +}); + +test('AP-35: explizites füllgewicht hat vorrang vor der ml-menge', function () { + $ing = psIngredient('PS-Balm'); + // 50 ml aber real 60 g Füllgewicht (dichter als Wasser) => 60 g Basis. + $product = psProduct(['contents_total' => 50, 'unit' => 1, 'fill_weight' => 60], $ing); + + $required = app(ProductionService::class)->requiredGramsByIngredient($product, 1); + expect($required[$ing->id])->toBe(60.0); +}); + +test('AP-35: ohne massenbasis (stück-produkt) gilt das altverhalten anteil=gramm', function () { + $ing = psIngredient('PS-Kapsel'); + // unit=2 (Stück) ohne fill_weight => keine Massenbasis => Faktor 1,0. + $product = psProduct(['contents_total' => 54, 'unit' => 2], $ing, gram: 30, factor: 1.0); + + $required = app(ProductionService::class)->requiredGramsByIngredient($product, 2); + expect($required[$ing->id])->toBe(60.0); // 30 × 1 × 2 (unskaliert) +}); + +test('AP-39: abwiegen darf bis 5 % vom soll abweichen und wird gebucht', function () { + $user = psUser(); + $this->actingAs($user, 'user'); + $ing = psIngredient('PS-Tol'); + $location = Location::factory()->create(); + $supplier = Supplier::factory()->create(); + StockEntry::query()->create([ + 'entry_type' => 'ingredient', 'ingredient_id' => $ing->id, 'supplier_id' => $supplier->id, + 'location_id' => $location->id, 'unit' => 'gram', 'ordered_by' => $user->id, 'ordered_at' => '2026-01-01', + 'ordered_quantity' => 1000, 'status' => 'received', 'received_by' => $user->id, 'received_at' => '2026-01-15', + 'received_quantity' => 1000, 'batch_number' => 'PS-B1', 'best_before' => '2030-12-31', + ]); + // 100 ml, 100 % => Soll 100 g je Stück. + $product = psProduct(['contents_total' => 100, 'unit' => 1], $ing); + + $stockId = StockEntry::query()->where('ingredient_id', $ing->id)->value('id'); + + // Ist 103 g (±3 %) => darf gespeichert werden, realer Wert wird gebucht. + $this->post(route('admin.inventory.productions.store'), [ + 'product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1, + 'ingredient_lines' => [['ingredient_id' => $ing->id, 'stock_entry_id' => $stockId, 'quantity_used' => '103']], + 'outputs' => [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]], + ])->assertSessionHasNoErrors(); + + expect(Production::query()->count())->toBe(1) + ->and((float) Production::query()->first()->productionIngredients()->sum('quantity_used'))->toBe(103.0); +}); + +test('AP-39: abweichung über 5 % wird abgelehnt (zahlendreher-schutz)', function () { + $user = psUser(); + $this->actingAs($user, 'user'); + $ing = psIngredient('PS-TolBad'); + $location = Location::factory()->create(); + $supplier = Supplier::factory()->create(); + StockEntry::query()->create([ + 'entry_type' => 'ingredient', 'ingredient_id' => $ing->id, 'supplier_id' => $supplier->id, + 'location_id' => $location->id, 'unit' => 'gram', 'ordered_by' => $user->id, 'ordered_at' => '2026-01-01', + 'ordered_quantity' => 1000, 'status' => 'received', 'received_by' => $user->id, 'received_at' => '2026-01-15', + 'received_quantity' => 1000, 'batch_number' => 'PS-B2', 'best_before' => '2030-12-31', + ]); + $product = psProduct(['contents_total' => 100, 'unit' => 1], $ing); + $stockId = StockEntry::query()->where('ingredient_id', $ing->id)->value('id'); + + // Ist 120 g (+20 %) => Ablehnung, keine Produktion. + $this->post(route('admin.inventory.productions.store'), [ + 'product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1, + 'ingredient_lines' => [['ingredient_id' => $ing->id, 'stock_entry_id' => $stockId, 'quantity_used' => '120']], + 'outputs' => [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]], + ])->assertSessionHasErrors('ingredient_lines'); + + expect(Production::query()->count())->toBe(0); +});