gruene-seele/app/Http/Controllers/Admin/Inventory/RawMaterialStockController.php
Kevin Adametz b8180a06a1 Warenwirtschaft: AP-53 — Gramm-Mengen mit drei Nachkommastellen (Produktion, Wareneingang, Ausschuss)
Kundennachtrag 09.09.2026: Ist-Mengen je Charge in der Produktion sollen auf das Milligramm eingegeben werden können (z. B. 0,125 g). Bisher rundeten Datenbank (decimal 12,2), Bestandslogik, Anzeige und Toleranzsockel auf zwei Stellen; „0,125" wurde still zu 0,13, „0.125" nach der Punkt-Regel zu 125 g.

- Migration 2026_09_09_100000: production_ingredients.quantity_used, stock_entries.ordered_quantity/received_quantity, stock_disposals.quantity → decimal(12,3); Casts decimal:3. Auf der Dev-DB (Live-Kopie) ausgeführt, 53/78/7 Zeilen unverändert.
- InventoryService::QUANTITY_DECIMALS = 3; roundWawiQuantity() ersetzt alle round(…, 2) in InventoryService, ProductionService, StockDisposalController/-Request, StockEntryRepository/-Controller, RawMaterialStockController.
- formatWawiQuantity($v, $min = 2): bis drei Stellen, mindestens zwei — Produktionsdetail, Einkauf Liste/Detail/Formular, Ausschuss-Liste, Chargen-Rest im Rohstoff-Detail.
- Eingaberegel (reFormatWawiNumber, JS parseGerman/parseNumber): zusätzlich „0.xxx" als Dezimalzahl; „1.500" bleibt 1500. Produktions-JS: fmt() bis drei Stellen, fmtInput() schreibt gespeicherte Zahlen deutsch ins Feld (1,125 statt 1.125 = 1125).
- Soll-Ist-Toleranz: Sockel 0,05 → 0,005 g; Ausschuss min 0,001.
- Doku: Hinweise (Neu/geändert), Leitfaden Version 3.1 mit Abschnitt 26, Plan V7 AP-53, README (Deploy-Checkliste, Suite 378).

Tests: QuantityPrecisionTest (6) — Parsing, Anzeige, Produktion 0,125 g exakt + Rest 9,875 g, Entwurf 1,125 wiederöffnen, Toleranz Kleinmengen, Ausschuss 0,005 g.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 15:54:29 +00:00

192 lines
9.1 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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);
$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,
'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)'),
];
}
}