gruene-seele/app/Services/InventoryService.php
Kevin Adametz 761ea35047 Warenwirtschaft: Bestandsrechnung auf der Rohstoff-Detailseite nachvollziehbar
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>
2026-09-17 10:34:04 +00:00

543 lines
24 KiB
PHP
Raw Permalink 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\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();
}
}