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

@ -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,

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);
}
}

View file

@ -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

View file

@ -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.

View file

@ -306,5 +306,160 @@
</table>
</div>
</div>
{{-- 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
<div class="wawi-card">
<a class="wawi-card__header d-flex justify-content-between align-items-center text-reset text-decoration-none collapsed"
data-toggle="collapse" href="#stock-breakdown" role="button" aria-expanded="false" aria-controls="stock-breakdown">
<span>
{{ __('Bestandsrechnung nachvollziehen') }}
<span class="text-muted small font-weight-normal ml-2">
{{ implode(', ', $summaryParts) }}
</span>
</span>
<span class="fas fa-chevron-down small text-muted"></span>
</a>
<div class="collapse" id="stock-breakdown">
<div class="table-responsive">
<table class="table wawi-table mb-0">
<thead>
<tr>
<th>{{ __('Eingang') }}</th>
<th>{{ __('Charge') }}</th>
<th>{{ __('Lagerort') }}</th>
<th>{{ __('MHD') }}</th>
<th class="text-right">{{ __('Eingang') }}</th>
<th class="text-right">{{ __('Produktion') }}</th>
<th class="text-right" title="{{ __('Ausschuss, der beim Erfassen dieser Charge zugeordnet wurde') }}">{{ __('Ausschuss (Charge)') }}</th>
<th class="text-right" title="{{ __('Ausschuss ohne Chargenbezug — wird rechnerisch nach MHD (FEFO) auf die Eingänge verteilt') }}">{{ __('Ausschuss (o. Charge)') }}</th>
<th class="text-right">{{ __('Rest') }}</th>
</tr>
</thead>
<tbody>
@forelse($bd['entries'] as $entry)
@php $isEmpty = (float) $entry->getAttribute('remaining_quantity') <= 0; @endphp
<tr class="{{ $isEmpty ? 'text-muted' : '' }}">
<td>
<a href="{{ route('admin.inventory.stock-entries.show', $entry) }}" class="text-reset">
{{ $entry->received_at?->format('d.m.Y') ?? '—' }}
</a>
<span class="text-muted small">#{{ $entry->id }}</span>
@if($entry->supplier)
<div class="wawi-item-sub">{{ $entry->supplier->name }}</div>
@endif
</td>
<td>{{ $entry->batch_number ?: '—' }}</td>
<td>{{ $entry->location?->name ?? '—' }}</td>
<td>{{ $entry->best_before?->format('d.m.Y') ?? '—' }}</td>
<td class="text-right">{{ formatWawiQuantity($entry->received_quantity) }} g</td>
<td class="text-right">{{ (float) $entry->getAttribute('production_quantity') > 0 ? '− ' . formatWawiQuantity($entry->getAttribute('production_quantity')) . ' g' : '—' }}</td>
<td class="text-right">{{ (float) $entry->getAttribute('bound_disposal_quantity') > 0 ? '− ' . formatWawiQuantity($entry->getAttribute('bound_disposal_quantity')) . ' g' : '—' }}</td>
<td class="text-right">{{ (float) $entry->getAttribute('unbound_disposal_quantity') > 0 ? '− ' . formatWawiQuantity($entry->getAttribute('unbound_disposal_quantity')) . ' g' : '—' }}</td>
<td class="text-right">
<strong>{{ formatWawiQuantity($entry->getAttribute('remaining_quantity')) }} g</strong>
@if($isEmpty)
<span class="badge badge-light border ml-1">{{ __('aufgebraucht') }}</span>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="9"><div class="wawi-empty">{{ __('Noch kein gebuchter Wareneingang.') }}</div></td>
</tr>
@endforelse
</tbody>
<tfoot>
<tr>
<td colspan="4" class="text-right text-muted">{{ __('Summe Wareneingänge') }}</td>
<td class="text-right">{{ formatWawiQuantity($bd['received_total']) }} g</td>
<td colspan="4"></td>
</tr>
<tr>
<td colspan="4" class="text-right text-muted">{{ __('− Produktionsverbrauch') }}</td>
<td class="text-right">− {{ formatWawiQuantity($bd['production_total']) }} g</td>
<td colspan="4" class="text-muted small">
@if($bd['production_other'] > 0)
{{ __('davon :qty g ohne zuordenbaren Eingang (z. B. Eingang nachträglich gelöscht)', ['qty' => formatWawiQuantity($bd['production_other'])]) }}
@endif
</td>
</tr>
<tr>
<td colspan="4" class="text-right text-muted">{{ __('− Ausschuss mit Chargenbezug') }}</td>
<td class="text-right">− {{ formatWawiQuantity($bd['disposed_bound_total'] + $bd['disposed_other']) }} g</td>
<td colspan="4" class="text-muted small">
@if($bd['disposed_other'] > 0)
{{ __('davon :qty g ohne zuordenbaren Eingang (Eingang nachträglich gelöscht)', ['qty' => formatWawiQuantity($bd['disposed_other'])]) }}
@endif
</td>
</tr>
<tr>
<td colspan="4" class="text-right text-muted">{{ __('− Ausschuss ohne Chargenbezug') }}</td>
<td class="text-right">− {{ formatWawiQuantity($bd['disposed_unbound_total']) }} g</td>
<td colspan="4" class="text-muted small">
@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
</td>
</tr>
<tr>
<td colspan="4" class="text-right"><strong>{{ __('= Gesamtbestand') }}</strong></td>
<td class="text-right"><strong>{{ formatWawiQuantity($bd['remaining_total']) }} g</strong></td>
<td colspan="4"></td>
</tr>
</tfoot>
</table>
</div>
@if($bd['unbound_disposals']->isNotEmpty())
<div class="wawi-card__footer" style="display:block;">
<div class="font-weight-bold mb-2">{{ __('Ausschuss ohne Chargenbezug') }}</div>
<p class="text-muted small mb-2">
{{ __('Diese Buchungen wurden ohne Zuordnung zu einem Eingang erfasst (Altbestand vor Einführung der Chargenwahl). Sie mindern den Gesamtbestand und werden in der Chargenansicht rechnerisch nach MHD (ältestes zuerst) von den Eingängen abgezogen — deshalb können auch neuere Eingänge als „aufgebraucht" erscheinen. Ist so eine Buchung überholt (z. B. doppelte Korrektur), lässt sie sich in der Ausschuss-Liste löschen.') }}
</p>
<div class="table-responsive">
<table class="table wawi-table table-sm mb-0">
<thead>
<tr>
<th>{{ __('Datum') }}</th>
<th>{{ __('Lagerort') }}</th>
<th>{{ __('Grund') }}</th>
<th>{{ __('Notiz') }}</th>
<th>{{ __('Erfasst von') }}</th>
<th class="text-right">{{ __('Menge') }}</th>
</tr>
</thead>
<tbody>
@foreach($bd['unbound_disposals'] as $disposal)
<tr>
<td>{{ $disposal->disposed_at?->format('d.m.Y') ?? $disposal->created_at?->format('d.m.Y') }} <span class="text-muted small">#{{ $disposal->id }}</span></td>
<td>{{ $disposal->location?->name ?? '—' }}</td>
<td>{{ $disposal->reason ?: '—' }}</td>
<td>{{ $disposal->note ?: '—' }}</td>
<td>{{ $disposal->user?->name ?? '—' }}</td>
<td class="text-right">− {{ formatWawiQuantity($disposal->quantity) }} g</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@if(Auth::user()->isAdmin())
<a href="{{ route('admin.inventory.stock-disposals.index') }}" class="btn btn-sm btn-outline-secondary mt-2">{{ __('Zur Ausschuss-Liste') }}</a>
@endif
</div>
@endif
</div>
</div>
</div>
@endsection

View file

@ -0,0 +1,127 @@
<?php
use App\Models\Ingredient;
use App\Models\Location;
use App\Models\StockDisposal;
use App\Models\StockEntry;
use App\Models\Supplier;
use App\Services\InventoryService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function rmbUser(): User
{
$user = User::query()->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]);
});