- N-2: Letzter Netto-Preis je Lieferant nur aus tatsächlichen Eingängen (status received). - N-3: Verbrauchsfenster exakt (kein startOfDay-Off-by-one). - N-4: Kritisch-Zählung vereinheitlicht — Controller liefert die Kachel-Zählweise (inkl. critical_ordered), tote View-Überschreibung entfernt; Sidenav-Badge bleibt bewusst ohne bestellte Kritische. - N-5: Ausschuss-Liste zeigt das 500er-Limit an + "Alle anzeigen" (?all=1), analog OPT-3. - N-7: parseQuantity parst Strings konsequent im deutschen Format — Server und UI-Ampel interpretieren "1.500" jetzt identisch. - N-8: Produktions-Kopie übernimmt keine alten Chargen-Nummern mehr (Auto-Vorschlag Präfix+ttmmjj greift). - N-9: recordProductionStock lädt Produkte withTrashed — Korrektur- Gegenbuchungen soft-gelöschter Output-Produkte werden nicht mehr übersprungen. - N-15: Hinweise/Leitfaden-Markdown mit html_input=strip + allow_unsafe_links=false gerendert. Bewusst offen (Kundenentscheid/Design): N-1 (Chargen-Gruppierung ohne supplier_id), N-6 (reFormatNumber-Konvention app-weit), N-10 (manuelle Bewegung darf ins Minus, Inventur-Korrekturen), N-13 (mfg_row_phase- Fallback auf phasenlos). Suite: 231 grün, 823 Assertions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
128 lines
5.7 KiB
PHP
128 lines
5.7 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,
|
|
// Audit N-4: gleiche Zählweise wie die Kacheln (kritisch inkl. bereits bestellt).
|
|
'criticalCount' => $rows->whereIn('status', ['critical', 'critical_ordered'])->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)'),
|
|
];
|
|
}
|
|
}
|