gruene-seele/app/Http/Controllers/Admin/Inventory/RawMaterialStockController.php
2026-09-08 11:04:07 +00:00

152 lines
7.2 KiB
PHP

<?php
namespace App\Http\Controllers\Admin\Inventory;
use App\Http\Controllers\Controller;
use App\Models\Ingredient;
use App\Models\Location;
use App\Models\TaxRate;
use App\Repositories\StockEntryRepository;
use App\Services\InventoryService;
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,
) {}
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: 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-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,
'taxRates' => TaxRate::query()->active()->orderBy('pos')->orderBy('name')->get(),
]);
}
/**
* @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)'),
];
}
}