Warenwirtschaft: F-14 — „Enthalten in“ mit Anteil (%) und Verbrauch je Stück

Nur Hersteller-Rezeptur, alle Phasenzeilen je Produkt summiert, Skalierung
über Füllgewicht und Faktor wie in der Produktion; kein roher Pivot-Wert mehr.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-08 11:07:22 +00:00
parent 1e7ebfb77b
commit 8808ff538f
4 changed files with 92 additions and 9 deletions

View file

@ -5,9 +5,12 @@ namespace App\Http\Controllers\Admin\Inventory;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Ingredient; use App\Models\Ingredient;
use App\Models\Location; use App\Models\Location;
use App\Models\Product;
use App\Models\ProductIngredient;
use App\Models\TaxRate; use App\Models\TaxRate;
use App\Repositories\StockEntryRepository; use App\Repositories\StockEntryRepository;
use App\Services\InventoryService; use App\Services\InventoryService;
use App\Services\ProductionService;
use App\Services\ProductStockService; use App\Services\ProductStockService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\View\View; use Illuminate\View\View;
@ -18,6 +21,7 @@ class RawMaterialStockController extends Controller
protected InventoryService $inventoryService, protected InventoryService $inventoryService,
protected ProductStockService $productStockService, protected ProductStockService $productStockService,
protected StockEntryRepository $stockEntryRepository, protected StockEntryRepository $stockEntryRepository,
protected ProductionService $productionService,
) {} ) {}
public function index(Request $request): View 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), 'products' => fn ($q) => $q->where('active', true)->where('production_relevant', true),
]); ]);
// AP-37: Jedes Produkt hat i. d. R. zwei product_ingredients-Zeilen // AP-37/Prüfung 08.09.2026 F-14: „Enthalten in" aus der Hersteller-Rezeptur —
// (recipe_type „manufacturer" + „product"), wodurch die belongsToMany- // alle Zeilen (Phasen) je Produkt summieren; Anteil (%) und Verbrauch je Stück
// Relation dasselbe Produkt doppelt liefert. Für die „Enthalten in"-Anzeige // (Anteil × Füllgewicht-Skala × Faktor, wie in der Produktion) statt roher Pivot-Wert.
// (und den darauf basierenden Produktbestand) nach Produkt-ID deduplizieren. $recipeUsage = $this->recipeUsageByProduct($ingredient);
$ingredient->setRelation('products', $ingredient->products->unique('id')->values()); $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). // AP-27: Chargen je batch_number zusammengeführt (jeder Einkauf bleibt eigener stock_entry).
$chargeGroups = $this->inventoryService->chargeGroupsForIngredient($ingredient->id); $chargeGroups = $this->inventoryService->chargeGroupsForIngredient($ingredient->id);
@ -133,10 +138,45 @@ class RawMaterialStockController extends Controller
'expectedEmpty' => $this->inventoryService->expectedEmptyDate($remaining, $daily), 'expectedEmpty' => $this->inventoryService->expectedEmptyDate($remaining, $daily),
'status' => $this->inventoryService->stockStatus($minAlert, $remaining, $daily, $leadDays, $openTotal > 0), 'status' => $this->inventoryService->stockStatus($minAlert, $remaining, $daily, $leadDays, $openTotal > 0),
'lastPriceBySupplier' => $lastPriceBySupplier, 'lastPriceBySupplier' => $lastPriceBySupplier,
'recipeUsage' => $recipeUsage,
'taxRates' => TaxRate::query()->active()->orderBy('pos')->orderBy('name')->get(), '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<int, array{percent: float, grams_per_piece: ?float, consumption_per_piece: ?float}>
*/
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<int, string> [days => label] * @return array<int, string> [days => label]
*/ */

View file

@ -395,6 +395,15 @@ class ProductionService
return $baseMass !== null ? $baseMass / 100.0 : 1.0; 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; * 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). * sonst gilt bei ml-Produkten (unit = 1) die Füllmenge als Gramm (1 g/ml).

View file

@ -95,25 +95,35 @@
<tr> <tr>
<th>{{ __('Produkt') }}</th> <th>{{ __('Produkt') }}</th>
<th class="text-right">{{ __('Produktbestand') }}</th> <th class="text-right">{{ __('Produktbestand') }}</th>
<th class="text-right">{{ __('Rezeptur-Anteil (g/Stück)') }}</th> <th class="text-right">{{ __('Anteil (%)') }}</th>
<th class="text-right" title="{{ __('Anteil × Füllgewicht × Überschuss-Faktor — wie in der Produktion gerechnet') }}">{{ __('Verbrauch (g/Stück)') }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@forelse($ingredient->products as $product) @forelse($ingredient->products as $product)
@php $usage = $recipeUsage[$product->id] ?? null; @endphp
<tr> <tr>
<td>{{ $product->name }}</td> <td>{{ $product->name }}</td>
<td class="text-right">{{ \App\Services\Util::formatNumber($productStock[$product->id] ?? 0, 0) }} {{ __('Stück') }}</td> <td class="text-right">{{ \App\Services\Util::formatNumber($productStock[$product->id] ?? 0, 0) }} {{ __('Stück') }}</td>
<td class="text-right"> <td class="text-right">
@if($product->pivot->gram !== null && $product->pivot->gram !== '') @if($usage)
{{ \App\Services\Util::formatNumber($product->pivot->gram, 2) }} g {{ \App\Services\Util::formatNumber($usage['percent'], 2) }} %
@else @else
<span class="text-muted">—</span> <span class="text-muted">—</span>
@endif @endif
</td> </td>
<td class="text-right">
@if($usage && $usage['consumption_per_piece'] !== null)
{{ \App\Services\Util::formatNumber($usage['consumption_per_piece'], 2) }} g
<span class="text-muted small">({{ __('netto') }} {{ \App\Services\Util::formatNumber($usage['grams_per_piece'], 2) }} g)</span>
@else
<span class="text-muted" title="{{ __('Kein Füllgewicht hinterlegt') }}">—</span>
@endif
</td>
</tr> </tr>
@empty @empty
<tr> <tr>
<td colspan="3"><div class="wawi-empty">{{ __('Dieser Rohstoff wird in keiner aktiven Rezeptur verwendet.') }}</div></td> <td colspan="4"><div class="wawi-empty">{{ __('Dieser Rohstoff wird in keiner aktiven Rezeptur verwendet.') }}</div></td>
</tr> </tr>
@endforelse @endforelse
</tbody> </tbody>

View file

@ -495,3 +495,27 @@ test('F-11: admins sehen den letzten einkaufspreis weiterhin', function () {
->assertSuccessful() ->assertSuccessful()
->assertSee('123,45'); ->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);
});