gruene-seele/app/Services/InventoryService.php
Kevin Adametz 313c6277ce Warenwirtschaft: N-01/N-03/N-05 — Entwurfsmenge ohne Charge, Snapshot-Signatur je Produkt, Alt-Ausschuss in Chargenverfügbarkeit
- N-01: Ist-Menge ohne gewählte Charge bleibt im Entwurf erhalten (Filter in
  Request und Service, formBatchKey liefert '' für chargenlose Zeilen).
- N-03: outputSignature summiert Stückzahlen je Produkt — ein Chargensplit
  ersetzt den historischen Verpackungs-Snapshot nicht mehr.
- N-05: Ausschuss ohne Chargenbezug (Altbestand) wird für die Verfügbarkeit je
  Standort FEFO auf die Chargen angerechnet (applyUnboundDisposals, lesend);
  Chargen-Ausschussprüfung nutzt dieselbe Verfügbarkeit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 11:48:02 +00:00

434 lines
18 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\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
{
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] = round((float) ($received[$id] ?? 0) - (float) ($consumed[$id] ?? 0) - (float) ($disposed[$id] ?? 0), 2);
}
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] = round((float) $qty, 2);
}
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] = round((float) ($received[$locationId] ?? 0) - (float) ($consumed[$locationId] ?? 0) - (float) ($disposed[$locationId] ?? 0), 2);
}
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] = round((float) ($received[$id] ?? 0) - (float) ($consumed[$id] ?? 0) - (float) ($disposed[$id] ?? 0), 2);
}
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] = round((float) $qty, 2);
}
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] = round((float) $qty, 2);
}
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', round($received - ($consumed[(int) $entry->id] ?? 0.0), 2));
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();
}
/**
* 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' => round($entries->sum(fn (StockEntry $e) => (float) $e->getAttribute('remaining_quantity')), 2),
'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();
}
}