Neuer Klappbereich „Bestandsrechnung nachvollziehen" zeigt alle gebuchten Eingänge – auch aufgebrauchte mit Rest 0 – mit Produktionsverbrauch, Ausschuss mit Chargenbezug und dem nach FEFO zugerechneten Ausschuss ohne Chargenbezug; Summenzeile bis zum Gesamtbestand identisch zur Kopfzahl. Alt-Ausschuss ohne Chargenbezug wird einzeln mit Grund/Notiz/Bearbeiter gelistet. Anlass: Live-Fall Sonnenblumenöl (doppelte Korrektur vom 28.07.). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
195 lines
9.3 KiB
PHP
195 lines
9.3 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\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);
|
||
// 17.09.2026: Bestandsrechnung zum Nachvollziehen (auch Eingänge mit Rest 0, Alt-Ausschuss).
|
||
$breakdown = $this->inventoryService->stockBreakdownForIngredient($ingredient->id);
|
||
|
||
$openOrders = $this->inventoryService->openOrdersForIngredient($ingredient->id);
|
||
$openTotal = roundWawiQuantity((float) $openOrders->sum('ordered_quantity'));
|
||
|
||
$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' => roundWawiQuantity($remaining),
|
||
'remainingByLocation' => $remainingByLocation,
|
||
'breakdown' => $breakdown,
|
||
'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 ? roundWawiQuantity($percent * $scale) : null,
|
||
'consumption_per_piece' => $hasMassBasis ? roundWawiQuantity($withFactor * $scale) : 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)'),
|
||
];
|
||
}
|
||
}
|