diff --git a/app/Http/Controllers/Admin/Inventory/RawMaterialStockController.php b/app/Http/Controllers/Admin/Inventory/RawMaterialStockController.php index af50c0c..3f3f65b 100644 --- a/app/Http/Controllers/Admin/Inventory/RawMaterialStockController.php +++ b/app/Http/Controllers/Admin/Inventory/RawMaterialStockController.php @@ -108,6 +108,8 @@ class RawMaterialStockController extends Controller $remaining = $this->inventoryService->remainingByIngredient([$ingredient->id])[$ingredient->id] ?? 0.0; $remainingByLocation = $this->inventoryService->remainingByLocationForIngredient($ingredient->id); + // 17.09.2026: Bestandsrechnung zum Nachvollziehen (auch Eingänge mit Rest 0, Alt-Ausschuss). + $breakdown = $this->inventoryService->stockBreakdownForIngredient($ingredient->id); $openOrders = $this->inventoryService->openOrdersForIngredient($ingredient->id); $openTotal = roundWawiQuantity((float) $openOrders->sum('ordered_quantity')); @@ -130,6 +132,7 @@ class RawMaterialStockController extends Controller 'chargeGroups' => $chargeGroups, 'remaining' => roundWawiQuantity($remaining), 'remainingByLocation' => $remainingByLocation, + 'breakdown' => $breakdown, 'locations' => Location::query()->where('active', true)->orderBy('name')->get(), 'openOrders' => $openOrders, 'openTotal' => $openTotal, diff --git a/app/Services/InventoryService.php b/app/Services/InventoryService.php index c370191..9b29fb5 100644 --- a/app/Services/InventoryService.php +++ b/app/Services/InventoryService.php @@ -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, + * unbound_disposals: Collection, + * 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), diff --git a/app/Services/ProductionService.php b/app/Services/ProductionService.php index 43d3869..084a524 100644 --- a/app/Services/ProductionService.php +++ b/app/Services/ProductionService.php @@ -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); } } diff --git a/resources/docs/hinweise.md b/resources/docs/hinweise.md index 7dec6ca..e691947 100644 --- a/resources/docs/hinweise.md +++ b/resources/docs/hinweise.md @@ -326,6 +326,20 @@ dadurch für die Bedienung ändert: Rohstoff/Artikel gesperrt. Auch Änderungen an offenen Bestellungen werden protokolliert (Grund dort optional). +### Neu 17.09.2026 (Rohstoff-Detail: Bestandsrechnung nachvollziehen) + +- **Klappbereich „Bestandsrechnung nachvollziehen“** unten auf der Rohstoff-Detailseite: + listet **alle** gebuchten Eingänge – auch die, die in „Verfügbare Chargen“ nicht mehr + erscheinen, weil ihr Rest 0 ist („aufgebraucht“) – mit Eingangsmenge, Produktionsverbrauch, + Ausschuss mit Chargenbezug und dem rechnerisch zugeordneten **Ausschuss ohne Chargenbezug**. + Die Summenzeile (Eingänge − Produktion − Ausschuss = Gesamtbestand) entspricht exakt der + Kopfzahl. Ausschuss-Buchungen ohne Chargenbezug (Altbestand) werden darunter einzeln mit + Datum, Grund, Notiz und Bearbeiter gezeigt. +- **Hintergrund:** Ausschuss ohne Chargenbezug mindert den Gesamtbestand und wird in der + Chargenansicht nach MHD (ältestes zuerst) auf die Eingänge verteilt – dadurch können auch + neuere Eingänge als aufgebraucht erscheinen. Ist so eine Buchung überholt (z. B. eine + doppelte Korrektur), lässt sie sich in der Ausschuss-Liste löschen. + ### In Vorbereitung / geplant - **Versand im System** (DHL-Etiketten per API, AP-32 — bis dahin gilt die diff --git a/resources/docs/testleitfaden.md b/resources/docs/testleitfaden.md index 6b762c7..96c7434 100644 --- a/resources/docs/testleitfaden.md +++ b/resources/docs/testleitfaden.md @@ -1,6 +1,6 @@ -# Testleitfaden Warenwirtschaft — Version 3.2 +# Testleitfaden Warenwirtschaft — Version 3.3 -> **Stand:** 14.09.2026 +> **Stand:** 17.09.2026 > Dieser Leitfaden führt einmal durch die Funktionen der Warenwirtschaft — in der Reihenfolge eines typischen Arbeitsablaufs, sodass jede Stelle direkt im Browser nachgeschaut und getestet werden kann. > Die fachlichen Erklärungen dazu stehen wie gewohnt in der App unter den **[Hinweisen zur Warenwirtschaft](/admin/inventory/notices)**. > @@ -436,6 +436,20 @@ --- +## 28. Rohstoff-Detail: Bestandsrechnung nachvollziehen (17.09.2026) + +**Wo:** **[Rohstoffbestand](/admin/inventory/raw-material-stock)** → Rohstoff öffnen → unten **„Bestandsrechnung nachvollziehen“** (aufklappen). + +**Neu:** Alle gebuchten Eingänge – auch aufgebrauchte (Rest 0), die in „Verfügbare Chargen“ nicht mehr stehen – mit Eingang, Produktion, Ausschuss (mit/ohne Chargenbezug) und Rest je Eingang; darunter die Summenzeile bis zum Gesamtbestand und die Liste der Ausschuss-Buchungen ohne Chargenbezug. + +**So testen:** +1. Rohstoff mit zwei Eingängen (z. B. 500 g MHD 2027, 1.000 g MHD 2028). Ausschuss **ohne Charge** über 700 g buchen → Chargenansicht zeigt nur noch den zweiten Eingang mit 800 g; im Klappbereich stehen **beide** Eingänge: erster „− 500 g / 0 g, aufgebraucht“, zweiter „− 200 g / 800 g“, Summenzeile „1.500 − 0 − 0 − 700 = 800 g“ = Kopfzahl. +2. Kopfzeile des Klappbereichs zeigt „2 Eingänge, 1 davon aufgebraucht, 1 Ausschuss ohne Chargenbezug“; die Ausschuss-Buchung steht mit Datum, Grund, Notiz und Bearbeiter darunter. +3. Aus dem zweiten Eingang 100 g produzieren → Spalte „Produktion“ − 100 g, Rest 700 g, Summenzeile entsprechend. +4. Eingang in der Tabelle anklicken → öffnet die Detailseite des Wareneingangs. + +--- + ## Hinweis am Rande Die Listen **Einkauf & Wareneingang**, **Produktionen** und die **Produktbestand-Historie** zeigen aus Performance-Gründen die neuesten 500 Einträge; darüber erscheint ein Link **„Alle anzeigen"**. Offene Bestellungen sind immer vollständig gelistet. diff --git a/resources/views/admin/inventory/raw-material-stock/show.blade.php b/resources/views/admin/inventory/raw-material-stock/show.blade.php index 600fe8d..ad87b3b 100644 --- a/resources/views/admin/inventory/raw-material-stock/show.blade.php +++ b/resources/views/admin/inventory/raw-material-stock/show.blade.php @@ -306,5 +306,160 @@ + + {{-- 17.09.2026: Bestandsrechnung zum Nachvollziehen — alle Eingänge (auch Rest 0), Verbrauch, Ausschuss --}} + @php + $bd = $breakdown; + $unboundCount = $bd['unbound_disposals']->count(); + $zeroCount = $bd['entries']->filter(fn ($e) => (float) $e->getAttribute('remaining_quantity') <= 0)->count(); + $summaryParts = [trans_choice(':count Eingang|:count Eingänge', $bd['entries']->count(), ['count' => $bd['entries']->count()])]; + if ($zeroCount > 0) { + $summaryParts[] = $zeroCount . ' ' . __('davon aufgebraucht'); + } + if ($unboundCount > 0) { + $summaryParts[] = $unboundCount . ' ' . __('Ausschuss ohne Chargenbezug'); + } + @endphp +
+ +
+
+ + + + + + + + + + + + + + + + @forelse($bd['entries'] as $entry) + @php $isEmpty = (float) $entry->getAttribute('remaining_quantity') <= 0; @endphp + + + + + + + + + + + + @empty + + + + @endforelse + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ __('Eingang') }}{{ __('Charge') }}{{ __('Lagerort') }}{{ __('MHD') }}{{ __('Eingang') }}{{ __('Produktion') }}{{ __('Ausschuss (Charge)') }}{{ __('Ausschuss (o. Charge)') }}{{ __('Rest') }}
+ + {{ $entry->received_at?->format('d.m.Y') ?? '—' }} + + #{{ $entry->id }} + @if($entry->supplier) +
{{ $entry->supplier->name }}
+ @endif +
{{ $entry->batch_number ?: '—' }}{{ $entry->location?->name ?? '—' }}{{ $entry->best_before?->format('d.m.Y') ?? '—' }}{{ formatWawiQuantity($entry->received_quantity) }} g{{ (float) $entry->getAttribute('production_quantity') > 0 ? '− ' . formatWawiQuantity($entry->getAttribute('production_quantity')) . ' g' : '—' }}{{ (float) $entry->getAttribute('bound_disposal_quantity') > 0 ? '− ' . formatWawiQuantity($entry->getAttribute('bound_disposal_quantity')) . ' g' : '—' }}{{ (float) $entry->getAttribute('unbound_disposal_quantity') > 0 ? '− ' . formatWawiQuantity($entry->getAttribute('unbound_disposal_quantity')) . ' g' : '—' }} + {{ formatWawiQuantity($entry->getAttribute('remaining_quantity')) }} g + @if($isEmpty) + {{ __('aufgebraucht') }} + @endif +
{{ __('Noch kein gebuchter Wareneingang.') }}
{{ __('Summe Wareneingänge') }}{{ formatWawiQuantity($bd['received_total']) }} g
{{ __('− Produktionsverbrauch') }}− {{ formatWawiQuantity($bd['production_total']) }} g + @if($bd['production_other'] > 0) + {{ __('davon :qty g ohne zuordenbaren Eingang (z. B. Eingang nachträglich gelöscht)', ['qty' => formatWawiQuantity($bd['production_other'])]) }} + @endif +
{{ __('− Ausschuss mit Chargenbezug') }}− {{ formatWawiQuantity($bd['disposed_bound_total'] + $bd['disposed_other']) }} g + @if($bd['disposed_other'] > 0) + {{ __('davon :qty g ohne zuordenbaren Eingang (Eingang nachträglich gelöscht)', ['qty' => formatWawiQuantity($bd['disposed_other'])]) }} + @endif +
{{ __('− Ausschuss ohne Chargenbezug') }}− {{ formatWawiQuantity($bd['disposed_unbound_total']) }} g + @if($bd['disposed_unbound_unallocated'] > 0) + {{ __('davon :qty g keinem Eingang zurechenbar (mehr Ausschuss als Eingang am Lagerort)', ['qty' => formatWawiQuantity($bd['disposed_unbound_unallocated'])]) }} + @endif +
{{ __('= Gesamtbestand') }}{{ formatWawiQuantity($bd['remaining_total']) }} g
+
+ + @if($bd['unbound_disposals']->isNotEmpty()) + + @endif +
+
@endsection diff --git a/tests/Feature/RawMaterialStockBreakdownTest.php b/tests/Feature/RawMaterialStockBreakdownTest.php new file mode 100644 index 0000000..4756437 --- /dev/null +++ b/tests/Feature/RawMaterialStockBreakdownTest.php @@ -0,0 +1,127 @@ +create([ + 'email' => uniqid('rmb_', true).'@test.example', + 'password' => bcrypt('password'), + ]); + $user->forceFill(['admin' => 7, 'confirmed' => true, 'active' => true, 'wizard' => 100, 'blocked' => false])->save(); + + return $user->fresh(); +} + +function rmbIngredient(): Ingredient +{ + return Ingredient::query()->create([ + 'name' => 'RMB-Öl', 'trans_name' => '', 'inci' => 'RMB', 'trans_inci' => '', + 'effect' => '', 'trans_effect' => '', 'active' => true, 'pos' => 0, + ]); +} + +function rmbStock(Ingredient $ing, Location $location, Supplier $supplier, User $user, float $qty, string $batch, string $bestBefore): StockEntry +{ + return StockEntry::query()->create([ + 'entry_type' => 'ingredient', 'ingredient_id' => $ing->id, + 'supplier_id' => $supplier->id, 'location_id' => $location->id, + 'unit' => 'gram', 'ordered_by' => $user->id, 'ordered_at' => '2026-01-01', + 'ordered_quantity' => $qty, 'status' => 'received', + 'received_by' => $user->id, 'received_at' => '2026-01-15', + 'received_quantity' => $qty, 'batch_number' => $batch, 'best_before' => $bestBefore, + ]); +} + +// 17.09.2026: Live-Fall Sonnenblumenöl — Alt-Ausschuss ohne Chargenbezug „frisst" nach FEFO +// die Eingänge auf; die Chargenansicht blendet Rest 0 aus. Die Bestandsrechnung zeigt alles. +test('bestandsrechnung listet auch aufgebrauchte eingaenge und rechnet den alt-ausschuss nach fefo zu', function () { + $user = rmbUser(); + $this->actingAs($user, 'user'); + $ing = rmbIngredient(); + $location = Location::factory()->create(); + $supplier = Supplier::factory()->create(); + + $old = rmbStock($ing, $location, $supplier, $user, 2900, 'ALT', '2028-09-30'); + $short = rmbStock($ing, $location, $supplier, $user, 874, 'KURZ', '2026-12-04'); + $big = rmbStock($ing, $location, $supplier, $user, 10164.16, 'NEU', '2028-09-30'); + $last = rmbStock($ing, $location, $supplier, $user, 6841.12, 'NEU', '2028-09-30'); + + // Ausschuss mit Chargenbezug auf die letzte Charge, dazu Alt-Ausschuss ohne Bezug. + StockDisposal::query()->create([ + 'disposal_type' => 'ingredient', 'ingredient_id' => $ing->id, 'stock_entry_id' => $last->id, + 'location_id' => $location->id, 'quantity' => 41.12, 'unit' => 'gram', 'reason' => 'Bruch', 'disposed_at' => '2026-07-01', + ]); + StockDisposal::query()->create([ + 'disposal_type' => 'ingredient', 'ingredient_id' => $ing->id, 'stock_entry_id' => null, + 'location_id' => $location->id, 'quantity' => 12100, 'unit' => 'gram', 'reason' => 'Korrektur', + 'note' => 'Initialbestand', 'disposed_at' => '2026-07-28', + ]); + + $svc = app(InventoryService::class); + $bd = $svc->stockBreakdownForIngredient($ing->id); + + // Kopfzahl und Summenzeile stimmen überein. + $header = $svc->remainingByIngredient([$ing->id])[$ing->id]; + expect($bd['remaining_total'])->toBe($header) + ->and($header)->toBe(roundWawiQuantity(20779.28 - 41.12 - 12100)) + ->and($bd['received_total'])->toBe(20779.28) + ->and($bd['disposed_bound_total'])->toBe(41.12) + ->and($bd['disposed_unbound_total'])->toBe(12100.0) + ->and($bd['disposed_unbound_unallocated'])->toBe(0.0) + ->and($bd['unbound_disposals'])->toHaveCount(1); + + // Alle vier Eingänge gelistet (Chargenansicht zeigt nur die mit Rest > 0). + expect($bd['entries'])->toHaveCount(4) + ->and($svc->chargesWithRemainingForIngredient($ing->id))->toHaveCount(2); + + $byId = $bd['entries']->keyBy('id'); + // FEFO: kurzes MHD zuerst, dann nach id. + expect((float) $byId[$short->id]->getAttribute('unbound_disposal_quantity'))->toBe(874.0) + ->and((float) $byId[$short->id]->getAttribute('remaining_quantity'))->toBe(0.0) + ->and((float) $byId[$old->id]->getAttribute('unbound_disposal_quantity'))->toBe(2900.0) + ->and((float) $byId[$old->id]->getAttribute('remaining_quantity'))->toBe(0.0) + ->and((float) $byId[$big->id]->getAttribute('unbound_disposal_quantity'))->toBe(8326.0) + ->and((float) $byId[$big->id]->getAttribute('remaining_quantity'))->toBe(1838.16) + ->and((float) $byId[$last->id]->getAttribute('bound_disposal_quantity'))->toBe(41.12) + ->and((float) $byId[$last->id]->getAttribute('unbound_disposal_quantity'))->toBe(0.0) + ->and((float) $byId[$last->id]->getAttribute('remaining_quantity'))->toBe(6800.0); + + // Seite: Klappbereich mit Zusammenfassung, aufgebrauchte Eingänge und Alt-Ausschuss sichtbar. + $res = $this->get(route('admin.inventory.raw-material-stock.show', $ing)); + $res->assertOk() + ->assertSee('Bestandsrechnung nachvollziehen') + ->assertSee('4 Eingänge, 2 davon aufgebraucht, 1 Ausschuss ohne Chargenbezug') + ->assertSee('KURZ') + ->assertSee('Initialbestand') + ->assertSee('= Gesamtbestand'); +}); + +test('bestandsrechnung weist alt-ausschuss ueber dem eingang als nicht zurechenbar aus', function () { + $user = rmbUser(); + $this->actingAs($user, 'user'); + $ing = rmbIngredient(); + $location = Location::factory()->create(); + $supplier = Supplier::factory()->create(); + rmbStock($ing, $location, $supplier, $user, 500, 'A', '2028-01-01'); + StockDisposal::query()->create([ + 'disposal_type' => 'ingredient', 'ingredient_id' => $ing->id, 'stock_entry_id' => null, + 'location_id' => $location->id, 'quantity' => 800, 'unit' => 'gram', 'reason' => 'Korrektur', 'disposed_at' => '2026-07-28', + ]); + + $bd = app(InventoryService::class)->stockBreakdownForIngredient($ing->id); + + expect($bd['disposed_unbound_unallocated'])->toBe(300.0) + ->and($bd['remaining_total'])->toBe(-300.0) + ->and($bd['remaining_total'])->toBe(app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id]); +});