Warenwirtschaft: AP-09 bis AP-13 (Produktbestand, Set-Produkte, Ausschuss, Konzepte)
- AP-09 Produktbestand inkl. Bewegungshistorie (product_stock_movements, ProductStockService) - AP-10 Rohstoffbestand-Ansicht je Lager (RawMaterialStockController) - AP-11 Bestandsschwellen / Out-of-Stock-Handling fuer Produkte und Shop - AP-12 Ausgang/Ausschuss (stock_disposals, StockDisposalController, InventoryService) - Set-Produkte (product_set_items) inkl. Aufloesung - Produktentwicklung & Hinweise-Verwaltung (Notices) - AP-13 Entwicklungskonzept Shop-Bestandsabzug im Plan dokumentiert - Feature-Tests fuer neue Module + aktualisierter Entwicklungsplan Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
78679e0c55
commit
3ee2d756e9
63 changed files with 5968 additions and 901 deletions
309
app/Services/InventoryService.php
Normal file
309
app/Services/InventoryService.php
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
<?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;
|
||||
|
||||
class InventoryService
|
||||
{
|
||||
/**
|
||||
* 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';
|
||||
}
|
||||
|
||||
/**
|
||||
* Anzahl der kritischen (Meldebestand unterschritten) aktiven Rohstoffe – für das Sidenav-Badge.
|
||||
*/
|
||||
public function criticalIngredientCount(): int
|
||||
{
|
||||
$ingredients = Ingredient::query()
|
||||
->where('active', true)
|
||||
->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();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue