Neuer Klappbereich „Bestandsrechnung nachvollziehen" zeigt alle gebuchten Eingänge – auch aufgebrauchte mit Rest 0 – mit Produktionsverbrauch, Ausschuss mit Chargenbezug und dem nach FEFO zugerechneten Ausschuss ohne Chargenbezug; Summenzeile bis zum Gesamtbestand identisch zur Kopfzahl. Alt-Ausschuss ohne Chargenbezug wird einzeln mit Grund/Notiz/Bearbeiter gelistet. Anlass: Live-Fall Sonnenblumenöl (doppelte Korrektur vom 28.07.). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
543 lines
24 KiB
PHP
543 lines
24 KiB
PHP
<?php
|
||
|
||
namespace App\Services;
|
||
|
||
use App\Models\Ingredient;
|
||
use App\Models\Production;
|
||
use App\Models\ProductionIngredient;
|
||
use App\Models\ProductionPackaging;
|
||
use App\Models\StockDisposal;
|
||
use App\Models\StockEntry;
|
||
use Carbon\Carbon;
|
||
use Illuminate\Database\Eloquent\Collection;
|
||
|
||
class InventoryService
|
||
{
|
||
/**
|
||
* AP-53 (09.09.2026): Gramm-Mengen (Produktion, Wareneingang, Ausschuss,
|
||
* Restbestände) werden einheitlich auf drei Nachkommastellen geführt.
|
||
*/
|
||
public const QUANTITY_DECIMALS = 3;
|
||
|
||
/** Obergrenze der decimal(12,3)-Spalten (Prüfung AP-53, Punkt 4.1) — wird validiert. */
|
||
public const QUANTITY_MAX = 999999999.999;
|
||
|
||
public function __construct(
|
||
protected ProductionService $productionService,
|
||
) {}
|
||
|
||
/**
|
||
* Lookback-Fenster (Tage) für die Berechnung des durchschnittlichen Tagesverbrauchs
|
||
* aus der Produktionshistorie.
|
||
*/
|
||
public const CONSUMPTION_WINDOW_DAYS = 90;
|
||
|
||
/**
|
||
* Standard-Lieferzeit (Tage), falls weder am Rohstoff noch am Lieferanten ein Wert gepflegt ist.
|
||
*/
|
||
public const DEFAULT_LEAD_DAYS = 14;
|
||
|
||
/**
|
||
* Gesamter Restbestand je Rohstoff in Gramm (nur eingegangene Chargen, abzüglich Produktionsverbrauch).
|
||
*
|
||
* @param array<int, int>|null $ingredientIds Optionaler Filter; null = alle.
|
||
* @return array<int, float> [ingredient_id => grams]
|
||
*/
|
||
public function remainingByIngredient(?array $ingredientIds = null): array
|
||
{
|
||
$received = StockEntry::query()
|
||
->where('status', 'received')
|
||
->where('entry_type', 'ingredient')
|
||
->whereNotNull('ingredient_id')
|
||
->when($ingredientIds !== null, fn ($q) => $q->whereIn('ingredient_id', $ingredientIds))
|
||
->selectRaw('ingredient_id, SUM(received_quantity) as qty')
|
||
->groupBy('ingredient_id')
|
||
->pluck('qty', 'ingredient_id');
|
||
|
||
$consumed = ProductionIngredient::query()
|
||
->join('productions', 'productions.id', '=', 'production_ingredients.production_id')
|
||
->whereNull('productions.deleted_at')
|
||
// AP-43: Entwürfe (nicht gebucht) zählen nicht in den Bestand.
|
||
->where('productions.status', '!=', Production::STATUS_DRAFT)
|
||
->when($ingredientIds !== null, fn ($q) => $q->whereIn('production_ingredients.ingredient_id', $ingredientIds))
|
||
->selectRaw('production_ingredients.ingredient_id as ingredient_id, SUM(production_ingredients.quantity_used) as qty')
|
||
->groupBy('production_ingredients.ingredient_id')
|
||
->pluck('qty', 'ingredient_id');
|
||
|
||
$disposed = $this->disposedByIngredient($ingredientIds);
|
||
|
||
// Audit M-5: über die Vereinigung aller Keys iterieren — Rohstoffe mit
|
||
// Verbrauch/Ausschuss, aber ohne Eingang, würden sonst fehlen und
|
||
// negative Bestände als 0 maskiert.
|
||
$ids = collect($received->keys())
|
||
->merge($consumed->keys())
|
||
->merge(array_keys($disposed))
|
||
->map(fn ($id) => (int) $id)
|
||
->unique();
|
||
|
||
$result = [];
|
||
foreach ($ids as $id) {
|
||
$result[$id] = roundWawiQuantity((float) ($received[$id] ?? 0) - (float) ($consumed[$id] ?? 0) - (float) ($disposed[$id] ?? 0));
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* Per Ausgang/Ausschuss entnommene Menge je Rohstoff in Gramm.
|
||
*
|
||
* @param array<int, int>|null $ingredientIds
|
||
* @return array<int, float> [ingredient_id => grams]
|
||
*/
|
||
public function disposedByIngredient(?array $ingredientIds = null): array
|
||
{
|
||
$rows = StockDisposal::query()
|
||
->where('disposal_type', 'ingredient')
|
||
->whereNotNull('ingredient_id')
|
||
->when($ingredientIds !== null, fn ($q) => $q->whereIn('ingredient_id', $ingredientIds))
|
||
->selectRaw('ingredient_id, SUM(quantity) as qty')
|
||
->groupBy('ingredient_id')
|
||
->pluck('qty', 'ingredient_id');
|
||
|
||
$result = [];
|
||
foreach ($rows as $id => $qty) {
|
||
$result[(int) $id] = roundWawiQuantity((float) $qty);
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* Restbestand eines Rohstoffs je Lagerort.
|
||
*
|
||
* @return array<int, float> [location_id => grams]
|
||
*/
|
||
public function remainingByLocationForIngredient(int $ingredientId): array
|
||
{
|
||
$received = StockEntry::query()
|
||
->where('status', 'received')
|
||
->where('entry_type', 'ingredient')
|
||
->where('ingredient_id', $ingredientId)
|
||
->selectRaw('location_id, SUM(received_quantity) as qty')
|
||
->groupBy('location_id')
|
||
->pluck('qty', 'location_id');
|
||
|
||
$consumed = ProductionIngredient::query()
|
||
->join('stock_entries', 'stock_entries.id', '=', 'production_ingredients.stock_entry_id')
|
||
->join('productions', 'productions.id', '=', 'production_ingredients.production_id')
|
||
->whereNull('productions.deleted_at')
|
||
// AP-43: Entwürfe (nicht gebucht) zählen nicht in den Bestand.
|
||
->where('productions.status', '!=', Production::STATUS_DRAFT)
|
||
->where('production_ingredients.ingredient_id', $ingredientId)
|
||
->selectRaw('stock_entries.location_id as location_id, SUM(production_ingredients.quantity_used) as qty')
|
||
->groupBy('stock_entries.location_id')
|
||
->pluck('qty', 'location_id');
|
||
|
||
// Audit M-5: chargen-bezogener Ausschuss zählt am Standort der Charge
|
||
// (COALESCE), nicht am frei gewählten Disposal-Standort.
|
||
$disposed = StockDisposal::query()
|
||
->leftJoin('stock_entries', 'stock_entries.id', '=', 'stock_disposals.stock_entry_id')
|
||
->where('stock_disposals.disposal_type', 'ingredient')
|
||
->where('stock_disposals.ingredient_id', $ingredientId)
|
||
->selectRaw('COALESCE(stock_entries.location_id, stock_disposals.location_id) as loc_id, SUM(stock_disposals.quantity) as qty')
|
||
->groupByRaw('COALESCE(stock_entries.location_id, stock_disposals.location_id)')
|
||
->pluck('qty', 'loc_id');
|
||
|
||
$ids = collect($received->keys())
|
||
->merge($consumed->keys())
|
||
->merge($disposed->keys())
|
||
->map(fn ($id) => (int) $id)
|
||
->unique();
|
||
|
||
$result = [];
|
||
foreach ($ids as $locationId) {
|
||
$result[$locationId] = roundWawiQuantity((float) ($received[$locationId] ?? 0) - (float) ($consumed[$locationId] ?? 0) - (float) ($disposed[$locationId] ?? 0));
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* Restbestand je Verpackungsartikel in Stück (Wareneingang − Produktionsverbrauch − Ausschuss).
|
||
*
|
||
* @param array<int, int>|null $packagingItemIds
|
||
* @return array<int, float> [packaging_item_id => pieces]
|
||
*/
|
||
public function remainingByPackagingItem(?array $packagingItemIds = null): array
|
||
{
|
||
$received = StockEntry::query()
|
||
->where('status', 'received')
|
||
->whereIn('entry_type', ['packaging', 'shipping'])
|
||
->whereNotNull('packaging_item_id')
|
||
->when($packagingItemIds !== null, fn ($q) => $q->whereIn('packaging_item_id', $packagingItemIds))
|
||
->selectRaw('packaging_item_id, SUM(received_quantity) as qty')
|
||
->groupBy('packaging_item_id')
|
||
->pluck('qty', 'packaging_item_id');
|
||
|
||
$consumed = ProductionPackaging::query()
|
||
->join('productions', 'productions.id', '=', 'production_packagings.production_id')
|
||
->whereNull('productions.deleted_at')
|
||
// AP-43: Entwürfe (nicht gebucht) zählen nicht in den Bestand.
|
||
->where('productions.status', '!=', Production::STATUS_DRAFT)
|
||
->when($packagingItemIds !== null, fn ($q) => $q->whereIn('production_packagings.packaging_item_id', $packagingItemIds))
|
||
->selectRaw('production_packagings.packaging_item_id as packaging_item_id, SUM(production_packagings.quantity_used) as qty')
|
||
->groupBy('production_packagings.packaging_item_id')
|
||
->pluck('qty', 'packaging_item_id');
|
||
|
||
$disposed = $this->disposedByPackagingItem($packagingItemIds);
|
||
|
||
$ids = collect($received->keys())
|
||
->merge($consumed->keys())
|
||
->merge(array_keys($disposed))
|
||
->map(fn ($id) => (int) $id)
|
||
->unique();
|
||
|
||
$result = [];
|
||
foreach ($ids as $id) {
|
||
$result[$id] = roundWawiQuantity((float) ($received[$id] ?? 0) - (float) ($consumed[$id] ?? 0) - (float) ($disposed[$id] ?? 0));
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* Per Ausgang/Ausschuss entnommene Menge je Verpackungsartikel in Stück.
|
||
*
|
||
* @param array<int, int>|null $packagingItemIds
|
||
* @return array<int, float> [packaging_item_id => pieces]
|
||
*/
|
||
public function disposedByPackagingItem(?array $packagingItemIds = null): array
|
||
{
|
||
$rows = StockDisposal::query()
|
||
->where('disposal_type', 'packaging')
|
||
->whereNotNull('packaging_item_id')
|
||
->when($packagingItemIds !== null, fn ($q) => $q->whereIn('packaging_item_id', $packagingItemIds))
|
||
->selectRaw('packaging_item_id, SUM(quantity) as qty')
|
||
->groupBy('packaging_item_id')
|
||
->pluck('qty', 'packaging_item_id');
|
||
|
||
$result = [];
|
||
foreach ($rows as $id => $qty) {
|
||
$result[(int) $id] = roundWawiQuantity((float) $qty);
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* Offene (bestellte, noch nicht eingegangene) Menge je Rohstoff in Gramm.
|
||
*
|
||
* @param array<int, int>|null $ingredientIds
|
||
* @return array<int, float> [ingredient_id => grams]
|
||
*/
|
||
public function openOrderQuantityByIngredient(?array $ingredientIds = null): array
|
||
{
|
||
$rows = StockEntry::query()
|
||
->where('status', 'pending')
|
||
->where('entry_type', 'ingredient')
|
||
->whereNotNull('ingredient_id')
|
||
->when($ingredientIds !== null, fn ($q) => $q->whereIn('ingredient_id', $ingredientIds))
|
||
->selectRaw('ingredient_id, SUM(ordered_quantity) as qty')
|
||
->groupBy('ingredient_id')
|
||
->pluck('qty', 'ingredient_id');
|
||
|
||
$result = [];
|
||
foreach ($rows as $id => $qty) {
|
||
$result[(int) $id] = roundWawiQuantity((float) $qty);
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* Durchschnittlicher Tagesverbrauch je Rohstoff (Gramm/Tag) aus der Produktionshistorie.
|
||
*
|
||
* @param array<int, int>|null $ingredientIds
|
||
* @return array<int, float> [ingredient_id => grams_per_day]
|
||
*/
|
||
public function dailyConsumptionByIngredient(?array $ingredientIds = null, int $windowDays = self::CONSUMPTION_WINDOW_DAYS): array
|
||
{
|
||
// Audit N-3: exaktes Fenster — startOfDay() verlängerte den Zeitraum um
|
||
// den angebrochenen heutigen Tag (Tagesverbrauch bis ~1 % überschätzt).
|
||
$windowDays = max(1, $windowDays);
|
||
$since = Carbon::now()->subDays($windowDays);
|
||
|
||
$rows = ProductionIngredient::query()
|
||
->join('productions', 'productions.id', '=', 'production_ingredients.production_id')
|
||
->whereNull('productions.deleted_at')
|
||
// AP-43: Entwürfe (nicht gebucht) zählen nicht in den Bestand.
|
||
->where('productions.status', '!=', Production::STATUS_DRAFT)
|
||
->where('productions.produced_at', '>=', $since)
|
||
->when($ingredientIds !== null, fn ($q) => $q->whereIn('production_ingredients.ingredient_id', $ingredientIds))
|
||
->selectRaw('production_ingredients.ingredient_id as ingredient_id, SUM(production_ingredients.quantity_used) as qty')
|
||
->groupBy('production_ingredients.ingredient_id')
|
||
->pluck('qty', 'ingredient_id');
|
||
|
||
$result = [];
|
||
foreach ($rows as $id => $qty) {
|
||
$result[(int) $id] = round((float) $qty / $windowDays, 4);
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* Tage bis der Bestand bei gleichbleibendem Verbrauch aufgebraucht ist (null = kein Verbrauch).
|
||
*/
|
||
public function daysUntilEmpty(float $remaining, ?float $dailyConsumption): ?int
|
||
{
|
||
if ($dailyConsumption === null || $dailyConsumption <= 0) {
|
||
return null;
|
||
}
|
||
if ($remaining <= 0) {
|
||
return 0;
|
||
}
|
||
|
||
return (int) floor($remaining / $dailyConsumption);
|
||
}
|
||
|
||
/**
|
||
* Voraussichtliches Datum, an dem der Bestand auf null fällt (null = kein Verbrauch).
|
||
*/
|
||
public function expectedEmptyDate(float $remaining, ?float $dailyConsumption): ?Carbon
|
||
{
|
||
$days = $this->daysUntilEmpty($remaining, $dailyConsumption);
|
||
if ($days === null) {
|
||
return null;
|
||
}
|
||
|
||
return Carbon::now()->startOfDay()->addDays($days);
|
||
}
|
||
|
||
/**
|
||
* Bestands-Status eines Rohstoffs:
|
||
* - "critical": Meldebestand gepflegt und unterschritten (rot, Badge-relevant).
|
||
* - "critical_ordered": kritisch, aber es existiert bereits eine offene Bestellung (entschärft).
|
||
* - "warning": Bestand reicht voraussichtlich nicht mehr bis zur nächsten Lieferung.
|
||
* - "ok": ausreichend.
|
||
*/
|
||
public function stockStatus(?float $minStockAlert, float $remaining, ?float $dailyConsumption, ?int $leadDays, bool $hasOpenOrder = false): string
|
||
{
|
||
if ($minStockAlert !== null && $minStockAlert > 0 && $remaining <= $minStockAlert) {
|
||
return $hasOpenOrder ? 'critical_ordered' : 'critical';
|
||
}
|
||
|
||
$days = $this->daysUntilEmpty($remaining, $dailyConsumption);
|
||
if ($days !== null) {
|
||
$lead = $leadDays !== null && $leadDays > 0 ? $leadDays : self::DEFAULT_LEAD_DAYS;
|
||
if ($days <= $lead) {
|
||
return 'warning';
|
||
}
|
||
}
|
||
|
||
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());
|
||
|
||
$entries = $entries->map(function (StockEntry $entry) use ($consumed) {
|
||
$received = $entry->received_quantity !== null ? (float) $entry->received_quantity : 0.0;
|
||
$entry->setAttribute('remaining_quantity', roundWawiQuantity($received - ($consumed[(int) $entry->id] ?? 0.0)));
|
||
|
||
return $entry;
|
||
});
|
||
|
||
// N-05: Alt-Ausschuss ohne Chargenbezug je Standort FEFO anrechnen (nur Anzeige/Verfügbarkeit).
|
||
return $this->productionService->applyUnboundDisposals($entries, $ingredientId)
|
||
->filter(fn (StockEntry $entry) => (float) $entry->getAttribute('remaining_quantity') > 0.0)
|
||
->values();
|
||
}
|
||
|
||
/**
|
||
* Bestandsrechnung eines Rohstoffs zum Nachvollziehen (17.09.2026): alle gebuchten
|
||
* Eingänge — auch die mit Rest 0 — mit Produktionsverbrauch, Ausschuss mit Chargenbezug
|
||
* und anteilig (FEFO) zugerechnetem Ausschuss ohne Chargenbezug. Die Summenzeile
|
||
* entspricht exakt remainingByIngredient(); Verbrauch/Ausschuss, der keinem gelisteten
|
||
* Eingang mehr zuzuordnen ist, wird als „sonstige" Position ausgewiesen.
|
||
*
|
||
* @return array{
|
||
* entries: Collection<int, StockEntry>,
|
||
* unbound_disposals: Collection<int, StockDisposal>,
|
||
* received_total: float,
|
||
* production_total: float,
|
||
* production_other: float,
|
||
* disposed_bound_total: float,
|
||
* disposed_unbound_total: float,
|
||
* disposed_unbound_unallocated: float,
|
||
* disposed_other: float,
|
||
* remaining_total: float
|
||
* }
|
||
*/
|
||
public function stockBreakdownForIngredient(int $ingredientId): array
|
||
{
|
||
$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();
|
||
|
||
$ids = $entries->pluck('id')->all();
|
||
|
||
$productionByEntry = ProductionIngredient::query()
|
||
->join('productions', 'productions.id', '=', 'production_ingredients.production_id')
|
||
->whereNull('productions.deleted_at')
|
||
->where('productions.status', '!=', Production::STATUS_DRAFT)
|
||
->whereIn('production_ingredients.stock_entry_id', $ids)
|
||
->selectRaw('production_ingredients.stock_entry_id as stock_entry_id, SUM(production_ingredients.quantity_used) as used')
|
||
->groupBy('production_ingredients.stock_entry_id')
|
||
->pluck('used', 'stock_entry_id');
|
||
|
||
$boundByEntry = StockDisposal::query()
|
||
->where('disposal_type', 'ingredient')
|
||
->whereIn('stock_entry_id', $ids)
|
||
->selectRaw('stock_entry_id, SUM(quantity) as used')
|
||
->groupBy('stock_entry_id')
|
||
->pluck('used', 'stock_entry_id');
|
||
|
||
$entries = $entries->map(function (StockEntry $entry) use ($productionByEntry, $boundByEntry) {
|
||
$received = $entry->received_quantity !== null ? (float) $entry->received_quantity : 0.0;
|
||
$production = roundWawiQuantity((float) ($productionByEntry[$entry->id] ?? 0.0));
|
||
$bound = roundWawiQuantity((float) ($boundByEntry[$entry->id] ?? 0.0));
|
||
$entry->setAttribute('production_quantity', $production);
|
||
$entry->setAttribute('bound_disposal_quantity', $bound);
|
||
$entry->setAttribute('unbound_disposal_quantity', 0.0);
|
||
$entry->setAttribute('remaining_quantity', roundWawiQuantity($received - $production - $bound));
|
||
|
||
return $entry;
|
||
});
|
||
|
||
// Gleiche FEFO-Zurechnung wie in der Chargenansicht — nur so stimmen beide Sichten überein.
|
||
$entries = $this->productionService->applyUnboundDisposals($entries, $ingredientId);
|
||
|
||
$unboundDisposals = StockDisposal::query()
|
||
->with(['location', 'user'])
|
||
->where('disposal_type', 'ingredient')
|
||
->where('ingredient_id', $ingredientId)
|
||
->whereNull('stock_entry_id')
|
||
->orderBy('disposed_at')
|
||
->orderBy('id')
|
||
->get();
|
||
|
||
$receivedTotal = roundWawiQuantity((float) $entries->sum(fn (StockEntry $e) => (float) ($e->received_quantity ?? 0)));
|
||
$productionByEntryTotal = roundWawiQuantity((float) $entries->sum(fn (StockEntry $e) => (float) $e->getAttribute('production_quantity')));
|
||
$productionTotal = roundWawiQuantity((float) ProductionIngredient::query()
|
||
->join('productions', 'productions.id', '=', 'production_ingredients.production_id')
|
||
->whereNull('productions.deleted_at')
|
||
->where('productions.status', '!=', Production::STATUS_DRAFT)
|
||
->where('production_ingredients.ingredient_id', $ingredientId)
|
||
->sum('production_ingredients.quantity_used'));
|
||
$disposedBoundTotal = roundWawiQuantity((float) $entries->sum(fn (StockEntry $e) => (float) $e->getAttribute('bound_disposal_quantity')));
|
||
$disposedUnboundTotal = roundWawiQuantity((float) $unboundDisposals->sum(fn (StockDisposal $d) => (float) $d->quantity));
|
||
$disposedUnboundAllocated = roundWawiQuantity((float) $entries->sum(fn (StockEntry $e) => (float) $e->getAttribute('unbound_disposal_quantity')));
|
||
$disposedTotal = $this->disposedByIngredient([$ingredientId])[$ingredientId] ?? 0.0;
|
||
|
||
return [
|
||
'entries' => $entries,
|
||
'unbound_disposals' => $unboundDisposals,
|
||
'received_total' => $receivedTotal,
|
||
'production_total' => $productionTotal,
|
||
'production_other' => roundWawiQuantity($productionTotal - $productionByEntryTotal),
|
||
'disposed_bound_total' => $disposedBoundTotal,
|
||
'disposed_unbound_total' => $disposedUnboundTotal,
|
||
'disposed_unbound_unallocated' => roundWawiQuantity($disposedUnboundTotal - $disposedUnboundAllocated),
|
||
'disposed_other' => roundWawiQuantity($disposedTotal - $disposedBoundTotal - $disposedUnboundTotal),
|
||
'remaining_total' => roundWawiQuantity($receivedTotal - $productionTotal - $disposedTotal),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* AP-27: Verfügbare Chargen eines Rohstoffs je Chargennummer zusammengeführt.
|
||
* Jeder Einkauf bleibt ein eigener stock_entry (in `entries` enthalten),
|
||
* Restbestand/Anzeige werden je batch_number summiert. FEFO-Reihenfolge bleibt erhalten.
|
||
*
|
||
* @return \Illuminate\Support\Collection<int, array{key: string, batch_number: ?string, entries: Collection<int, StockEntry>, remaining: float, best_before: ?Carbon, suppliers: \Illuminate\Support\Collection<int, string>, locations: \Illuminate\Support\Collection<int, string>}>
|
||
*/
|
||
public function chargeGroupsForIngredient(int $ingredientId): \Illuminate\Support\Collection
|
||
{
|
||
return $this->chargesWithRemainingForIngredient($ingredientId)
|
||
->groupBy(fn (StockEntry $entry) => $entry->batchGroupKey())
|
||
->map(function (Collection $entries, string $key) {
|
||
/** @var StockEntry $first */
|
||
$first = $entries->first();
|
||
|
||
return [
|
||
'key' => $key,
|
||
'batch_number' => $first->batch_number,
|
||
'entries' => $entries->values(),
|
||
'remaining' => roundWawiQuantity($entries->sum(fn (StockEntry $e) => (float) $e->getAttribute('remaining_quantity'))),
|
||
'best_before' => $entries->pluck('best_before')->filter()->min(),
|
||
'suppliers' => $entries->map(fn (StockEntry $e) => $e->supplier?->name)->filter()->unique()->values(),
|
||
'locations' => $entries->map(fn (StockEntry $e) => $e->location?->name)->filter()->unique()->values(),
|
||
];
|
||
})
|
||
->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.
|
||
*/
|
||
public function criticalIngredientCount(): int
|
||
{
|
||
$ingredients = Ingredient::query()
|
||
->where('active', true)
|
||
->where('exclude_from_stock', false)
|
||
->whereNotNull('min_stock_alert')
|
||
->where('min_stock_alert', '>', 0)
|
||
->get(['id', 'min_stock_alert']);
|
||
|
||
if ($ingredients->isEmpty()) {
|
||
return 0;
|
||
}
|
||
|
||
$ids = $ingredients->pluck('id')->all();
|
||
$remaining = $this->remainingByIngredient($ids);
|
||
$openOrders = $this->openOrderQuantityByIngredient($ids);
|
||
|
||
return $ingredients->filter(function (Ingredient $ingredient) use ($remaining, $openOrders) {
|
||
$rem = $remaining[$ingredient->id] ?? 0.0;
|
||
if ($rem > (float) $ingredient->min_stock_alert) {
|
||
return false;
|
||
}
|
||
|
||
// Kritisch, aber bereits nachbestellt => entschärft, nicht im Badge zählen.
|
||
return ($openOrders[$ingredient->id] ?? 0.0) <= 0;
|
||
})->count();
|
||
}
|
||
}
|