- 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>
421 lines
17 KiB
PHP
421 lines
17 KiB
PHP
<?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();
|
||
}
|
||
}
|