Kundennachtrag 09.09.2026: Ist-Mengen je Charge in der Produktion sollen auf das Milligramm eingegeben werden können (z. B. 0,125 g). Bisher rundeten Datenbank (decimal 12,2), Bestandslogik, Anzeige und Toleranzsockel auf zwei Stellen; „0,125" wurde still zu 0,13, „0.125" nach der Punkt-Regel zu 125 g. - Migration 2026_09_09_100000: production_ingredients.quantity_used, stock_entries.ordered_quantity/received_quantity, stock_disposals.quantity → decimal(12,3); Casts decimal:3. Auf der Dev-DB (Live-Kopie) ausgeführt, 53/78/7 Zeilen unverändert. - InventoryService::QUANTITY_DECIMALS = 3; roundWawiQuantity() ersetzt alle round(…, 2) in InventoryService, ProductionService, StockDisposalController/-Request, StockEntryRepository/-Controller, RawMaterialStockController. - formatWawiQuantity($v, $min = 2): bis drei Stellen, mindestens zwei — Produktionsdetail, Einkauf Liste/Detail/Formular, Ausschuss-Liste, Chargen-Rest im Rohstoff-Detail. - Eingaberegel (reFormatWawiNumber, JS parseGerman/parseNumber): zusätzlich „0.xxx" als Dezimalzahl; „1.500" bleibt 1500. Produktions-JS: fmt() bis drei Stellen, fmtInput() schreibt gespeicherte Zahlen deutsch ins Feld (1,125 statt 1.125 = 1125). - Soll-Ist-Toleranz: Sockel 0,05 → 0,005 g; Ausschuss min 0,001. - Doku: Hinweise (Neu/geändert), Leitfaden Version 3.1 mit Abschnitt 26, Plan V7 AP-53, README (Deploy-Checkliste, Suite 378). Tests: QuantityPrecisionTest (6) — Parsing, Anzeige, Produktion 0,125 g exakt + Rest 9,875 g, Entwurf 1,125 wiederöffnen, Toleranz Kleinmengen, Ausschuss 0,005 g. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
440 lines
18 KiB
PHP
440 lines
18 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;
|
||
|
||
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();
|
||
}
|
||
|
||
/**
|
||
* 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();
|
||
}
|
||
}
|