gruene-seele/app/Services/InventoryService.php
Kevin Adametz 7a5b5e0f7b Warenwirtschaft: AP-27 Wareneingang gleiche Charge zusammenführen
- Chargen-Existenz-Check beim Buchen (JSON-Endpoint batch-check): Hinweis
  „Charge bereits im Haus" + Vorbelegung MHD/Qualität aus dem letzten Eingang
- Rohstoffbestand-Detail: Chargen je batch_number summiert
  (InventoryService::chargeGroupsForIngredient), Einkäufe als Unterzeilen
- Produktion: Charge-Dropdown je Charge mit Summen-Rest (batches im
  Recipe-Payload), Verbrauch wird per expandBatchLines FEFO auf die
  Einzel-Einkäufe der Charge verteilt; Service-API bleibt je stock_entry
- Jeder Einkauf bleibt eigener stock_entry (Preis/Datum/Audit)
- Hinweise-Doku + Plan V6.0 §6 fortgeschrieben

Tests: StockEntryChargeMergeTest (7 neu); Suite 193 grün, 698 Assertions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:36:19 +00:00

388 lines
15 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()
->when($ingredientIds !== null, fn ($q) => $q->whereIn('ingredient_id', $ingredientIds))
->selectRaw('ingredient_id, SUM(quantity_used) as qty')
->groupBy('ingredient_id')
->pluck('qty', 'ingredient_id');
$disposed = $this->disposedByIngredient($ingredientIds);
$result = [];
foreach ($received as $id => $qty) {
$result[(int) $id] = round((float) $qty - (float) ($consumed[$id] ?? 0) - (float) ($disposed[(int) $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')
->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');
$disposed = StockDisposal::query()
->where('disposal_type', 'ingredient')
->where('ingredient_id', $ingredientId)
->selectRaw('location_id, SUM(quantity) as qty')
->groupBy('location_id')
->pluck('qty', 'location_id');
$result = [];
foreach ($received as $locationId => $qty) {
$result[(int) $locationId] = round((float) $qty - (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()
->when($packagingItemIds !== null, fn ($q) => $q->whereIn('packaging_item_id', $packagingItemIds))
->selectRaw('packaging_item_id, SUM(quantity_used) as qty')
->groupBy('packaging_item_id')
->pluck('qty', 'packaging_item_id');
$disposed = $this->disposedByPackagingItem($packagingItemIds);
$result = [];
foreach ($received as $id => $qty) {
$result[(int) $id] = round((float) $qty - (float) ($consumed[$id] ?? 0) - (float) ($disposed[(int) $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
{
$windowDays = max(1, $windowDays);
$since = Carbon::now()->subDays($windowDays)->startOfDay();
$rows = ProductionIngredient::query()
->join('productions', 'productions.id', '=', 'production_ingredients.production_id')
->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();
}
}