gruene-seele/app/Http/Controllers/Admin/Inventory/RawMaterialStockController.php
Kevin Adametz 3ee2d756e9 Warenwirtschaft: AP-09 bis AP-13 (Produktbestand, Set-Produkte, Ausschuss, Konzepte)
- AP-09 Produktbestand inkl. Bewegungshistorie (product_stock_movements, ProductStockService)
- AP-10 Rohstoffbestand-Ansicht je Lager (RawMaterialStockController)
- AP-11 Bestandsschwellen / Out-of-Stock-Handling fuer Produkte und Shop
- AP-12 Ausgang/Ausschuss (stock_disposals, StockDisposalController, InventoryService)
- Set-Produkte (product_set_items) inkl. Aufloesung
- Produktentwicklung & Hinweise-Verwaltung (Notices)
- AP-13 Entwicklungskonzept Shop-Bestandsabzug im Plan dokumentiert
- Feature-Tests fuer neue Module + aktualisierter Entwicklungsplan

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 11:04:22 +00:00

173 lines
6.9 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\StockEntry;
use App\Models\TaxRate;
use App\Services\InventoryService;
use App\Services\ProductionService;
use App\Services\ProductStockService;
use Illuminate\View\View;
class RawMaterialStockController extends Controller
{
public function __construct(
protected InventoryService $inventoryService,
protected ProductionService $productionService,
protected ProductStockService $productStockService,
) {}
public function index(): View
{
$ingredients = Ingredient::query()
->where('active', true)
->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),
];
});
return view('admin.inventory.raw-material-stock.index', [
'rows' => $rows,
'criticalCount' => $rows->where('status', 'critical')->count(),
'horizonOptions' => $this->horizonOptions(),
'defaultHorizon' => 90,
]);
}
public function show(Ingredient $ingredient): View
{
$ingredient->load([
'materialQuality',
'taxRate',
'suppliers' => fn ($q) => $q->orderByPivot('preferred', 'desc')->orderBy('name'),
'products' => fn ($q) => $q->where('active', true),
]);
$entries = StockEntry::query()
->with(['supplier', 'location'])
->where('status', 'received')
->where('entry_type', 'ingredient')
->where('ingredient_id', $ingredient->id)
->orderByRaw('best_before is null, best_before asc')
->orderBy('id')
->get();
$consumed = $this->productionService->consumedByStockEntry($entries->pluck('id')->all());
$charges = $entries->map(function (StockEntry $entry) use ($consumed) {
$received = $entry->received_quantity !== null ? (float) $entry->received_quantity : 0.0;
$entry->setAttribute('remaining_quantity', round($received - ($consumed[(int) $entry->id] ?? 0.0), 2));
return $entry;
})->filter(fn (StockEntry $entry) => (float) $entry->getAttribute('remaining_quantity') > 0.0)
->values();
$remaining = array_sum(array_map(
fn (StockEntry $entry) => (float) $entry->getAttribute('remaining_quantity'),
$charges->all()
));
$remainingByLocation = $this->inventoryService->remainingByLocationForIngredient($ingredient->id);
$openOrders = StockEntry::query()
->with(['supplier', 'location'])
->where('status', 'pending')
->where('entry_type', 'ingredient')
->where('ingredient_id', $ingredient->id)
->orderBy('ordered_at')
->orderBy('id')
->get();
$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;
$lastPriceBySupplier = $this->lastNetPricePerSupplier($ingredient->id);
$productStock = $this->productStockService->currentStockByProduct($ingredient->products->pluck('id')->all());
return view('admin.inventory.raw-material-stock.show', [
'ingredient' => $ingredient,
'productStock' => $productStock,
'charges' => $charges,
'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(),
]);
}
/**
* Letzter Netto-kg-Preis je Lieferant für diesen Rohstoff (für die Bestell-/Lieferantenliste).
*
* @return array<int, float> [supplier_id => price_per_kg_net]
*/
protected function lastNetPricePerSupplier(int $ingredientId): array
{
$entries = StockEntry::query()
->where('entry_type', 'ingredient')
->where('ingredient_id', $ingredientId)
->whereNotNull('supplier_id')
->whereNotNull('price_per_kg')
->orderBy('ordered_at', 'desc')
->orderBy('id', 'desc')
->get(['supplier_id', 'price_per_kg']);
$result = [];
foreach ($entries as $entry) {
$supplierId = (int) $entry->supplier_id;
if (! isset($result[$supplierId])) {
$result[$supplierId] = (float) $entry->price_per_kg;
}
}
return $result;
}
/**
* @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)'),
];
}
}