- consumedByStockEntry(): chargen-bezogener Ausschuss zählt jetzt als Verbrauch — entsorgte Chargen verschwinden aus Rohstoff-Detail, Produktions-FEFO/Dropdown und AP-27-Batch-Check. - Rohstoff-Detail: Kopfwert aus remainingByIngredient() (identisch zur Index-Seite, inkl. Ausschuss ohne Chargenbezug). - Aggregationen iterieren über die Key-Vereinigung (received ∪ consumed ∪ disposed) — negative Bestände werden nicht mehr als 0 maskiert. - Standort-Aggregation: Chargen-Ausschuss zählt am Standort der Charge (COALESCE), nicht am frei gewählten Formular-Standort. Tests: neuer ChargeDisposalConsistencyTest (5). Suite: 219 grün, 795 Assertions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
127 lines
5.6 KiB
PHP
127 lines
5.6 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)"
|
|
$scope = $request->query('scope') === 'recipe' ? 'recipe' : 'all';
|
|
|
|
$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),
|
|
];
|
|
});
|
|
|
|
return view('admin.inventory.raw-material-stock.index', [
|
|
'rows' => $rows,
|
|
'criticalCount' => $rows->where('status', 'critical')->count(),
|
|
'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'),
|
|
'products' => fn ($q) => $q->where('active', true),
|
|
]);
|
|
|
|
// 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;
|
|
|
|
$lastPriceBySupplier = $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)'),
|
|
];
|
|
}
|
|
}
|