Warenwirtschaft: OPT-3 Index-Limits + OPT-4 show()-Refactoring

- Wareneingang-/Produktions-Uebersicht: Eingegangene auf 500 gedeckelt
  (offene Bestellungen immer vollstaendig), ?all=1 + Hinweis-Link;
  ungenutzte User-Eager-Loads entfernt
- RawMaterialStockController::show() entschlackt: Chargen-Restmengen (FEFO)
  und offene Bestellungen im InventoryService, letzter Netto-Preis je
  Lieferant als Window-Function-Query im StockEntryRepository

Tests: RawMaterialStockTest +2, volle Suite 186 gruen, 663 Assertions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-06 12:23:20 +00:00
parent 209ea467eb
commit e47d848d3d
10 changed files with 165 additions and 75 deletions

View file

@ -22,10 +22,13 @@ class ProductionController extends Controller
protected ProductionService $productionService
) {}
public function index(): View
public function index(Request $request): View
{
$limit = $request->boolean('all') ? null : 500;
return view('admin.inventory.productions.index', [
'values' => $this->productionRepository->listForIndex(),
'values' => $this->productionRepository->listForIndex($limit),
'listLimit' => $limit,
]);
}

View file

@ -7,8 +7,8 @@ use App\Models\Ingredient;
use App\Models\Location;
use App\Models\StockEntry;
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;
@ -17,8 +17,8 @@ class RawMaterialStockController extends Controller
{
public function __construct(
protected InventoryService $inventoryService,
protected ProductionService $productionService,
protected ProductStockService $productStockService,
protected StockEntryRepository $stockEntryRepository,
) {}
public function index(Request $request): View
@ -76,47 +76,19 @@ class RawMaterialStockController extends Controller
'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()
));
$charges = $this->inventoryService->chargesWithRemainingForIngredient($ingredient->id);
$remaining = round((float) $charges->sum(fn (StockEntry $entry) => (float) $entry->getAttribute('remaining_quantity')), 2);
$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();
$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->lastNetPricePerSupplier($ingredient->id);
$lastPriceBySupplier = $this->stockEntryRepository->lastNetPricePerSupplier($ingredient->id);
$productStock = $this->productStockService->currentStockByProduct($ingredient->products->pluck('id')->all());
@ -138,33 +110,6 @@ class RawMaterialStockController extends Controller
]);
}
/**
* 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]
*/

View file

@ -25,10 +25,13 @@ class StockEntryController extends Controller
protected StockEntryRepository $stockEntryRepository
) {}
public function index(): View
public function index(Request $request): View
{
$limit = $request->boolean('all') ? null : 500;
return view('admin.inventory.stock-entries.index', array_merge($this->formSharedData(), [
'values' => $this->stockEntryRepository->listForIndex(),
'values' => $this->stockEntryRepository->listForIndex($limit),
'listLimit' => $limit,
]));
}

View file

@ -8,14 +8,17 @@ use Illuminate\Database\Eloquent\Collection;
class ProductionRepository
{
/**
* Neueste zuerst, gedeckelt auf $limit (OPT-3); null = alle.
*
* @return Collection<int, Production>
*/
public function listForIndex(): Collection
public function listForIndex(?int $limit = 500): Collection
{
return Production::query()
->with(['product', 'location'])
->orderByDesc('produced_at')
->orderByDesc('id')
->when($limit !== null, fn ($q) => $q->limit($limit))
->get();
}
}

View file

@ -120,12 +120,35 @@ class StockEntryRepository
->get();
}
/**
* Letzter Netto-kg-Preis je Lieferant für einen Rohstoff (neuester Einkauf zählt).
*
* @return array<int, float> [supplier_id => price_per_kg_net]
*/
public function lastNetPricePerSupplier(int $ingredientId): array
{
$ranked = StockEntry::query()
->selectRaw('supplier_id, price_per_kg, ROW_NUMBER() OVER (PARTITION BY supplier_id ORDER BY ordered_at DESC, id DESC) as rn')
->where('entry_type', 'ingredient')
->where('ingredient_id', $ingredientId)
->whereNotNull('supplier_id')
->whereNotNull('price_per_kg');
return StockEntry::query()
->fromSub($ranked, 'ranked')
->where('rn', 1)
->pluck('price_per_kg', 'supplier_id')
->map(fn ($price) => (float) $price)
->all();
}
/**
* Liste: Pending zuerst (neuestes Bestelldatum), dann Received (neuester Eingang).
* Eingegangene Einträge sind auf $receivedLimit gedeckelt (OPT-3); null = alle.
*
* @return Collection<int, StockEntry>
*/
public function listForIndex(): Collection
public function listForIndex(?int $receivedLimit = 500): Collection
{
$with = [
'ingredient',
@ -133,12 +156,20 @@ class StockEntryRepository
'supplier',
'location',
'quality',
'orderedByUser',
'receivedByUser',
];
$pending = StockEntry::query()->with($with)->where('status', 'pending')->orderByDesc('ordered_at')->get();
$received = StockEntry::query()->with($with)->where('status', 'received')->orderByDesc('received_at')->get();
$pending = StockEntry::query()->with($with)
->where('status', 'pending')
->orderByDesc('ordered_at')
->orderByDesc('id')
->get();
$received = StockEntry::query()->with($with)
->where('status', 'received')
->orderByDesc('received_at')
->orderByDesc('id')
->when($receivedLimit !== null, fn ($q) => $q->limit($receivedLimit))
->get();
return $pending->concat($received)->values();
}

View file

@ -8,9 +8,14 @@ use App\Models\ProductionPackaging;
use App\Models\StockDisposal;
use App\Models\StockEntry;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Collection;
class InventoryService
{
public function __construct(
protected ProductionService $productionService,
) {}
/**
* Lookback-Fenster (Tage) für die Berechnung des durchschnittlichen Tagesverbrauchs
* aus der Produktionshistorie.
@ -277,6 +282,51 @@ class InventoryService
return 'ok';
}
/**
* Eingegangene Chargen eines Rohstoffs mit Restmenge (> 0), FEFO-sortiert
* (frühestes MHD zuerst). Restmenge = Eingang − Produktionsverbrauch je Charge.
*
* @return Collection<int, StockEntry>
*/
public function chargesWithRemainingForIngredient(int $ingredientId)
{
$entries = StockEntry::query()
->with(['supplier', 'location'])
->where('status', 'received')
->where('entry_type', 'ingredient')
->where('ingredient_id', $ingredientId)
->orderByRaw('best_before is null, best_before asc')
->orderBy('id')
->get();
$consumed = $this->productionService->consumedByStockEntry($entries->pluck('id')->all());
return $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();
}
/**
* Offene (bestellte, noch nicht eingegangene) Einkäufe eines Rohstoffs.
*
* @return Collection<int, StockEntry>
*/
public function openOrdersForIngredient(int $ingredientId)
{
return StockEntry::query()
->with(['supplier', 'location'])
->where('status', 'pending')
->where('entry_type', 'ingredient')
->where('ingredient_id', $ingredientId)
->orderBy('ordered_at')
->orderBy('id')
->get();
}
/**
* Anzahl der kritischen (Meldebestand unterschritten) aktiven Rohstoffe – für das Sidenav-Badge.
*/