Warenwirtschaft: AP-35 + AP-39 — Produktionsmengen auf Füllgewicht + ±5 % Abwiegen
AP-35 (Mengenberechnung): Die Hersteller-Rezeptur ist in Prozent hinterlegt; die Sollmengen skalieren jetzt auf das Füllgewicht statt fest auf ~100 g. Neuer Faktor recipeGramScale = Füllgewicht/100. Basis = neues, optionales Produkt-Feld "fill_weight" (g); ist es leer, gilt bei ml-Produkten (unit=1) die Füllmenge (contents_total) als Gramm (1 g/ml). Ohne Massenbasis (z. B. Stück-Produkte) bleibt es beim Altverhalten (Anteil = Gramm), sodass Bestandsprodukte/Tests unverändert rechnen. Skala wird an die Maske durchgereicht (recipe_gram_scale), damit Client und Server identisch rechnen (Service + 4 JS-Stellen). AP-39 (Abwiegen): Die Ist-Menge je INCI muss nicht mehr aufs Gramm passen — assertLinesMatchRecipe erlaubt ±5 % (kleiner absoluter Sockel), gebucht wird der reale Ist-Wert. Status-Ampel in der Maske: Orange = nichts eingetragen, Grün = eingetragen und ±5 %, Rot = >5 % daneben (Zahlendreher-Schutz). Migration add_fill_weight_to_products auf Dev-/Live-DB ausgeführt (nullable). Product fillable/cast + Produktformular-Feld ergänzt. 5 Tests, Suite 310 grün. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d60947c34d
commit
744b36cdcb
6 changed files with 238 additions and 11 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) => [
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* AP-35 (Kundenentscheid 26.08.2026): Die Hersteller-Rezeptur ist in Prozent
|
||||
* hinterlegt; die Produktions-Sollmengen skalieren auf das Füllgewicht des
|
||||
* Produkts. „fill_weight" (Gramm je Stück) hat Vorrang; ist es leer, gilt bei
|
||||
* ml-Produkten die Füllmenge (contents_total) als Gramm (1 g/ml).
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('products', function (Blueprint $table) {
|
||||
$table->integer('fill_weight')->nullable()->after('contents_total');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('products', function (Blueprint $table) {
|
||||
$table->dropColumn('fill_weight');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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 = $('<div class="border rounded p-2 mb-2" data-ingredient-id="' + ing.id + '"></div>');
|
||||
$wrap.data('recipe-ing', ing);
|
||||
var $head = $('<div class="d-flex justify-content-between flex-wrap"></div>');
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -530,6 +530,11 @@
|
|||
<label class="form-label" for="contents_total">{{ __('Gesamter Inhalt (ml / g) als Zahl') }}</label>
|
||||
{{ Form::text('contents_total', $product->contents_total, ['placeholder' => __('Bsp: 150'), 'class' => 'form-control', 'id' => 'contents_total']) }}
|
||||
</div>
|
||||
<div class="form-group col-sm-3">
|
||||
<label class="form-label" for="fill_weight">{{ __('Füllgewicht (g) – für Produktion') }}</label>
|
||||
{{ Form::text('fill_weight', $product->fill_weight, ['placeholder' => __('leer = Menge = Gramm (1 g/ml)'), 'class' => 'form-control', 'id' => 'fill_weight']) }}
|
||||
<small class="form-text text-muted">{{ __('Basis für die Rezeptur-Mengenberechnung (Prozent × Füllgewicht).') }}</small>
|
||||
</div>
|
||||
<div class="form-group col-sm-2">
|
||||
<label class="form-label" for="contents">{{ __('Grundpreis Einheit') }}</label>
|
||||
{{ Form::select('unit', $product->unitTypes, $product->unit, ['data-live-search' => 'false', 'class' => 'selectpicker', 'id' => 'unit']) }}
|
||||
|
|
|
|||
137
tests/Feature/ProductionScalingTest.php
Normal file
137
tests/Feature/ProductionScalingTest.php
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Ingredient;
|
||||
use App\Models\Location;
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductIngredient;
|
||||
use App\Models\Production;
|
||||
use App\Models\StockEntry;
|
||||
use App\Models\Supplier;
|
||||
use App\Services\ProductionService;
|
||||
use App\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
/**
|
||||
* AP-35: Rezeptur ist in Prozent; die Sollmenge skaliert auf das Füllgewicht.
|
||||
* AP-39: Abwiegen darf ±5 % vom Soll abweichen (realer Ist-Wert wird gebucht).
|
||||
* Helfer sind mit ps* präfixiert, um Kollisionen mit anderen Testdateien zu meiden.
|
||||
*/
|
||||
function psUser(): User
|
||||
{
|
||||
$user = User::query()->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<string, mixed> $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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue