gruene-seele/app/Services/InventoryService.php
Kevin Adametz d34f0a77f4 Warenwirtschaft: Audit-Niedrigbefunde N-2/3/4/5/7/8/9/15
- N-2: Letzter Netto-Preis je Lieferant nur aus tatsächlichen
  Eingängen (status received).
- N-3: Verbrauchsfenster exakt (kein startOfDay-Off-by-one).
- N-4: Kritisch-Zählung vereinheitlicht — Controller liefert die
  Kachel-Zählweise (inkl. critical_ordered), tote View-Überschreibung
  entfernt; Sidenav-Badge bleibt bewusst ohne bestellte Kritische.
- N-5: Ausschuss-Liste zeigt das 500er-Limit an + "Alle anzeigen"
  (?all=1), analog OPT-3.
- N-7: parseQuantity parst Strings konsequent im deutschen Format —
  Server und UI-Ampel interpretieren "1.500" jetzt identisch.
- N-8: Produktions-Kopie übernimmt keine alten Chargen-Nummern mehr
  (Auto-Vorschlag Präfix+ttmmjj greift).
- N-9: recordProductionStock lädt Produkte withTrashed — Korrektur-
  Gegenbuchungen soft-gelöschter Output-Produkte werden nicht mehr
  übersprungen.
- N-15: Hinweise/Leitfaden-Markdown mit html_input=strip +
  allow_unsafe_links=false gerendert.

Bewusst offen (Kundenentscheid/Design): N-1 (Chargen-Gruppierung ohne
supplier_id), N-6 (reFormatNumber-Konvention app-weit), N-10 (manuelle
Bewegung darf ins Minus, Inventur-Korrekturen), N-13 (mfg_row_phase-
Fallback auf phasenlos).

Suite: 231 grün, 823 Assertions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 12:57:26 +00:00

421 lines
17 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\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')
->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')
->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')
->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')
->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());
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();
}
/**
* 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();
}
}