gruene-seele/app/Http/Controllers/Admin/Inventory/RawMaterialStockController.php
Kevin Adametz 8808ff538f 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>
2026-09-08 11:07:22 +00:00

192 lines
9 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
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;
class RawMaterialStockController extends Controller
{
public function __construct(
protected InventoryService $inventoryService,
protected ProductStockService $productStockService,
protected StockEntryRepository $stockEntryRepository,
protected ProductionService $productionService,
) {}
public function index(Request $request): View
{
// AP-20: Modus „alle" / „nur aus Herstellerrezepturen (aktiver Produkte)"
// Kundenentscheid 17.07.2026: Default = nur Rohstoffe aus Herstellerrezepturen
$scope = $request->query('scope') === 'all' ? 'all' : 'recipe';
$ingredients = Ingredient::query()
->where('active', true)
->where('exclude_from_stock', false)
->when($scope === 'recipe', fn ($q) => $q->usedInActiveRecipes())
->with('materialQuality')
->orderBy('pos')
->orderBy('name')
->get();
$ids = $ingredients->pluck('id')->all();
$remaining = $this->inventoryService->remainingByIngredient($ids);
$daily = $this->inventoryService->dailyConsumptionByIngredient($ids);
$openOrders = $this->inventoryService->openOrderQuantityByIngredient($ids);
$rows = $ingredients->map(function (Ingredient $ingredient) use ($remaining, $daily, $openOrders) {
$rem = $remaining[$ingredient->id] ?? 0.0;
$perDay = $daily[$ingredient->id] ?? null;
$open = $openOrders[$ingredient->id] ?? 0.0;
$minAlert = $ingredient->min_stock_alert !== null ? (float) $ingredient->min_stock_alert : null;
$leadDays = $ingredient->delivery_time_days !== null ? (int) $ingredient->delivery_time_days : null;
return [
'ingredient' => $ingredient,
'remaining' => $rem,
'daily' => $perDay,
'open_order' => $open,
'days_until_empty' => $this->inventoryService->daysUntilEmpty($rem, $perDay),
'expected_empty' => $this->inventoryService->expectedEmptyDate($rem, $perDay),
'status' => $this->inventoryService->stockStatus($minAlert, $rem, $perDay, $leadDays, $open > 0),
];
});
$criticalCount = $rows->whereIn('status', ['critical', 'critical_ordered'])->count();
// Review 20.07.2026: Das Sidebar-Badge zählt ALLE aktiven kritischen Rohstoffe,
// die Standardansicht (Scope „recipe") blendet aber rezepturlose aus. Ohne
// Hinweis widersprächen sich Badge (z. B. „1") und Seite („0 kritisch").
// Differenz nur im recipe-Scope ausweisen.
$hiddenCriticalCount = $scope === 'recipe'
? max(0, $this->inventoryService->criticalIngredientCount() - $criticalCount)
: 0;
return view('admin.inventory.raw-material-stock.index', [
'rows' => $rows,
// Audit N-4: gleiche Zählweise wie die Kacheln (kritisch inkl. bereits bestellt).
'criticalCount' => $criticalCount,
'hiddenCriticalCount' => $hiddenCriticalCount,
'horizonOptions' => $this->horizonOptions(),
'defaultHorizon' => 90,
'scope' => $scope,
]);
}
public function show(Ingredient $ingredient): View
{
$ingredient->load([
'materialQuality',
'taxRate',
'suppliers' => fn ($q) => $q->orderByPivot('preferred', 'desc')->orderBy('name'),
// AP-47: nur produktionsrelevante Produkte in „Enthalten in" (blendet z. B.
// Aloe-Handelsware und Zweitgrößen aus).
'products' => fn ($q) => $q->where('active', true)->where('production_relevant', true),
]);
// 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);
// Audit H-1: Kopfwert aus der Gesamt-Aggregation (zieht auch Ausschuss
// ohne Chargenbezug ab) — identisch zur Index-Seite, nicht Chargen-Summe.
$remaining = $this->inventoryService->remainingByIngredient([$ingredient->id])[$ingredient->id] ?? 0.0;
$remainingByLocation = $this->inventoryService->remainingByLocationForIngredient($ingredient->id);
$openOrders = $this->inventoryService->openOrdersForIngredient($ingredient->id);
$openTotal = round((float) $openOrders->sum('ordered_quantity'), 2);
$daily = ($this->inventoryService->dailyConsumptionByIngredient([$ingredient->id]))[$ingredient->id] ?? null;
$minAlert = $ingredient->min_stock_alert !== null ? (float) $ingredient->min_stock_alert : null;
$leadDays = $ingredient->delivery_time_days !== null ? (int) $ingredient->delivery_time_days : null;
// Prüfung 08.09.2026 F-11: Einkaufspreise nur für Admins (wie Einkauf/Wareneingang),
// Copyreader (Versand/Produktion) sehen die Rohstoffseite ohne Preise.
$lastPriceBySupplier = auth()->user()->isAdmin()
? $this->stockEntryRepository->lastNetPricePerSupplier($ingredient->id)
: [];
$productStock = $this->productStockService->currentStockByProduct($ingredient->products->pluck('id')->all());
return view('admin.inventory.raw-material-stock.show', [
'ingredient' => $ingredient,
'productStock' => $productStock,
'chargeGroups' => $chargeGroups,
'remaining' => round($remaining, 2),
'remainingByLocation' => $remainingByLocation,
'locations' => Location::query()->where('active', true)->orderBy('name')->get(),
'openOrders' => $openOrders,
'openTotal' => $openTotal,
'daily' => $daily,
'daysUntilEmpty' => $this->inventoryService->daysUntilEmpty($remaining, $daily),
'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<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]
*/
protected function horizonOptions(): array
{
return [
30 => __('Verbrauch (nächster Monat)'),
90 => __('Verbrauch (nächste 3 Monate)'),
180 => __('Verbrauch (nächste 6 Monate)'),
365 => __('Verbrauch (nächste 12 Monate)'),
];
}
}