152 lines
7.2 KiB
PHP
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)'),
|
|
];
|
|
}
|
|
}
|