Warenwirtschaft: Bestandsrechnung auf der Rohstoff-Detailseite nachvollziehbar

Neuer Klappbereich „Bestandsrechnung nachvollziehen" zeigt alle gebuchten
Eingänge – auch aufgebrauchte mit Rest 0 – mit Produktionsverbrauch,
Ausschuss mit Chargenbezug und dem nach FEFO zugerechneten Ausschuss ohne
Chargenbezug; Summenzeile bis zum Gesamtbestand identisch zur Kopfzahl.
Alt-Ausschuss ohne Chargenbezug wird einzeln mit Grund/Notiz/Bearbeiter
gelistet. Anlass: Live-Fall Sonnenblumenöl (doppelte Korrektur vom 28.07.).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-17 10:34:04 +00:00
parent 79bec0ff0b
commit 761ea35047
7 changed files with 417 additions and 2 deletions

View file

@ -365,6 +365,106 @@ class InventoryService
->values();
}
/**
* Bestandsrechnung eines Rohstoffs zum Nachvollziehen (17.09.2026): alle gebuchten
* Eingänge — auch die mit Rest 0 — mit Produktionsverbrauch, Ausschuss mit Chargenbezug
* und anteilig (FEFO) zugerechnetem Ausschuss ohne Chargenbezug. Die Summenzeile
* entspricht exakt remainingByIngredient(); Verbrauch/Ausschuss, der keinem gelisteten
* Eingang mehr zuzuordnen ist, wird als „sonstige" Position ausgewiesen.
*
* @return array{
* entries: Collection<int, StockEntry>,
* unbound_disposals: Collection<int, StockDisposal>,
* received_total: float,
* production_total: float,
* production_other: float,
* disposed_bound_total: float,
* disposed_unbound_total: float,
* disposed_unbound_unallocated: float,
* disposed_other: float,
* remaining_total: float
* }
*/
public function stockBreakdownForIngredient(int $ingredientId): array
{
$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();
$ids = $entries->pluck('id')->all();
$productionByEntry = ProductionIngredient::query()
->join('productions', 'productions.id', '=', 'production_ingredients.production_id')
->whereNull('productions.deleted_at')
->where('productions.status', '!=', Production::STATUS_DRAFT)
->whereIn('production_ingredients.stock_entry_id', $ids)
->selectRaw('production_ingredients.stock_entry_id as stock_entry_id, SUM(production_ingredients.quantity_used) as used')
->groupBy('production_ingredients.stock_entry_id')
->pluck('used', 'stock_entry_id');
$boundByEntry = StockDisposal::query()
->where('disposal_type', 'ingredient')
->whereIn('stock_entry_id', $ids)
->selectRaw('stock_entry_id, SUM(quantity) as used')
->groupBy('stock_entry_id')
->pluck('used', 'stock_entry_id');
$entries = $entries->map(function (StockEntry $entry) use ($productionByEntry, $boundByEntry) {
$received = $entry->received_quantity !== null ? (float) $entry->received_quantity : 0.0;
$production = roundWawiQuantity((float) ($productionByEntry[$entry->id] ?? 0.0));
$bound = roundWawiQuantity((float) ($boundByEntry[$entry->id] ?? 0.0));
$entry->setAttribute('production_quantity', $production);
$entry->setAttribute('bound_disposal_quantity', $bound);
$entry->setAttribute('unbound_disposal_quantity', 0.0);
$entry->setAttribute('remaining_quantity', roundWawiQuantity($received - $production - $bound));
return $entry;
});
// Gleiche FEFO-Zurechnung wie in der Chargenansicht — nur so stimmen beide Sichten überein.
$entries = $this->productionService->applyUnboundDisposals($entries, $ingredientId);
$unboundDisposals = StockDisposal::query()
->with(['location', 'user'])
->where('disposal_type', 'ingredient')
->where('ingredient_id', $ingredientId)
->whereNull('stock_entry_id')
->orderBy('disposed_at')
->orderBy('id')
->get();
$receivedTotal = roundWawiQuantity((float) $entries->sum(fn (StockEntry $e) => (float) ($e->received_quantity ?? 0)));
$productionByEntryTotal = roundWawiQuantity((float) $entries->sum(fn (StockEntry $e) => (float) $e->getAttribute('production_quantity')));
$productionTotal = roundWawiQuantity((float) ProductionIngredient::query()
->join('productions', 'productions.id', '=', 'production_ingredients.production_id')
->whereNull('productions.deleted_at')
->where('productions.status', '!=', Production::STATUS_DRAFT)
->where('production_ingredients.ingredient_id', $ingredientId)
->sum('production_ingredients.quantity_used'));
$disposedBoundTotal = roundWawiQuantity((float) $entries->sum(fn (StockEntry $e) => (float) $e->getAttribute('bound_disposal_quantity')));
$disposedUnboundTotal = roundWawiQuantity((float) $unboundDisposals->sum(fn (StockDisposal $d) => (float) $d->quantity));
$disposedUnboundAllocated = roundWawiQuantity((float) $entries->sum(fn (StockEntry $e) => (float) $e->getAttribute('unbound_disposal_quantity')));
$disposedTotal = $this->disposedByIngredient([$ingredientId])[$ingredientId] ?? 0.0;
return [
'entries' => $entries,
'unbound_disposals' => $unboundDisposals,
'received_total' => $receivedTotal,
'production_total' => $productionTotal,
'production_other' => roundWawiQuantity($productionTotal - $productionByEntryTotal),
'disposed_bound_total' => $disposedBoundTotal,
'disposed_unbound_total' => $disposedUnboundTotal,
'disposed_unbound_unallocated' => roundWawiQuantity($disposedUnboundTotal - $disposedUnboundAllocated),
'disposed_other' => roundWawiQuantity($disposedTotal - $disposedBoundTotal - $disposedUnboundTotal),
'remaining_total' => roundWawiQuantity($receivedTotal - $productionTotal - $disposedTotal),
];
}
/**
* AP-27: Verfügbare Chargen eines Rohstoffs je Chargennummer zusammengeführt.
* Jeder Einkauf bleibt ein eigener stock_entry (in `entries` enthalten),

View file

@ -605,6 +605,8 @@ class ProductionService
}
$take = roundWawiQuantity(min($remaining, $needed));
$entry->setAttribute('remaining_quantity', roundWawiQuantity($remaining - $take));
// Bestandsrechnung (17.09.2026): anteilig zugerechneten Alt-Ausschuss am Eingang merken.
$entry->setAttribute('unbound_disposal_quantity', roundWawiQuantity((float) ($entry->getAttribute('unbound_disposal_quantity') ?? 0.0) + $take));
$needed = roundWawiQuantity($needed - $take);
}
}