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:
parent
209ea467eb
commit
e47d848d3d
10 changed files with 165 additions and 75 deletions
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue