Warenwirtschaft: N-01/N-03/N-05 — Entwurfsmenge ohne Charge, Snapshot-Signatur je Produkt, Alt-Ausschuss in Chargenverfügbarkeit

- N-01: Ist-Menge ohne gewählte Charge bleibt im Entwurf erhalten (Filter in
  Request und Service, formBatchKey liefert '' für chargenlose Zeilen).
- N-03: outputSignature summiert Stückzahlen je Produkt — ein Chargensplit
  ersetzt den historischen Verpackungs-Snapshot nicht mehr.
- N-05: Ausschuss ohne Chargenbezug (Altbestand) wird für die Verfügbarkeit je
  Standort FEFO auf die Chargen angerechnet (applyUnboundDisposals, lesend);
  Chargen-Ausschussprüfung nutzt dieselbe Verfügbarkeit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-08 11:48:02 +00:00
parent 520d4e2deb
commit 313c6277ce
8 changed files with 138 additions and 23 deletions

View file

@ -113,8 +113,8 @@ class StockDisposalController extends Controller
if (! empty($data['stock_entry_id'])) {
$entry = StockEntry::query()->lockForUpdate()->findOrFail((int) $data['stock_entry_id']);
$consumed = $productionService->consumedByStockEntry([$entry->id])[$entry->id] ?? 0.0;
$remaining = round((float) $entry->received_quantity - $consumed, 2);
$available = $productionService->availableStockEntriesForIngredient($ingredientId, (int) $entry->location_id)->firstWhere('id', $entry->id);
$remaining = $available !== null ? round((float) $available->getAttribute('remaining_quantity'), 2) : 0.0;
} else {
// Alle Eingänge des Rohstoffs am Standort sperren, dann Standortrest neu berechnen.
StockEntry::query()->lockForUpdate()

View file

@ -98,14 +98,17 @@ class StoreProductionRequest extends FormRequest
// AP-43: Im Entwurf dürfen Zeilen unvollständig sein — nur solche mit
// Rohstoff + (Charge oder Wareneingang) bzw. Ergebnis-Produkt übernehmen.
$ingredientLines = array_values(array_filter($data['ingredient_lines'] ?? [], function ($line): bool {
// N-01 (Nachprüfung 08.09.2026): Im Entwurf zählt auch eine Ist-Menge ohne
// gewählte Charge als Arbeitsstand; beim Abschluss verlangen die Regeln die Charge.
$isDraft = $this->isDraft();
$ingredientLines = array_values(array_filter($data['ingredient_lines'] ?? [], function ($line) use ($isDraft): bool {
return is_array($line)
&& (int) ($line['ingredient_id'] ?? 0) > 0
&& (! empty($line['batch_key']) || (int) ($line['stock_entry_id'] ?? 0) > 0);
&& (! empty($line['batch_key']) || (int) ($line['stock_entry_id'] ?? 0) > 0
|| ($isDraft && trim((string) ($line['quantity_used'] ?? '')) !== ''));
}));
// F-03: Im Entwurf zählt ein gewähltes Ergebnisprodukt auch ohne Stückzahl als Arbeitsstand.
$isDraft = $this->isDraft();
$outputs = array_values(array_filter($data['outputs'] ?? [], function ($output) use ($isDraft): bool {
return is_array($output)
&& (int) ($output['product_id'] ?? 0) > 0
@ -128,7 +131,8 @@ class StoreProductionRequest extends FormRequest
if (! empty($line['batch_key'])) {
$out['batch_key'] = (string) $line['batch_key'];
} else {
$out['stock_entry_id'] = (int) $line['stock_entry_id'];
// N-01: im Entwurf darf beides fehlen (Menge ohne Charge).
$out['stock_entry_id'] = (int) ($line['stock_entry_id'] ?? 0);
}
return $out;

View file

@ -113,8 +113,10 @@ class StoreStockDisposalRequest extends FormRequest
return;
}
$consumed = app(ProductionService::class)->consumedByStockEntry([$entry->id])[$entry->id] ?? 0.0;
$remaining = round((float) $entry->received_quantity - $consumed, 2);
// N-05: Verfügbarkeit der Charge wie in der Produktion (inkl. FEFO-Anrechnung von Alt-Ausschuss ohne Charge).
$available = app(ProductionService::class)->availableStockEntriesForIngredient($ingredientId, (int) $entry->location_id)
->firstWhere('id', $entry->id);
$remaining = $available !== null ? round((float) $available->getAttribute('remaining_quantity'), 2) : 0.0;
if ($quantity > $remaining) {
$validator->errors()->add('quantity', __('Die Menge (:qty g) übersteigt den Restbestand der Charge (:rest g).', [
'qty' => number_format($quantity, 2, ',', '.'),

View file

@ -35,6 +35,10 @@ class ProductionIngredient extends Model
return (string) $this->batch_key;
}
if ($this->stock_entry_id === null) {
return ''; // N-01: Entwurfszeile ohne Charge — Menge bleibt, Charge wird später gewählt.
}
return $this->stockEntry?->batchGroupKey() ?? 'e:'.$this->stock_entry_id;
}

View file

@ -343,12 +343,16 @@ class InventoryService
$consumed = $this->productionService->consumedByStockEntry($entries->pluck('id')->all());
return $entries->map(function (StockEntry $entry) use ($consumed) {
$entries = $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)
});
// N-05: Alt-Ausschuss ohne Chargenbezug je Standort FEFO anrechnen (nur Anzeige/Verfügbarkeit).
return $this->productionService->applyUnboundDisposals($entries, $ingredientId)
->filter(fn (StockEntry $entry) => (float) $entry->getAttribute('remaining_quantity') > 0.0)
->values();
}

View file

@ -350,13 +350,15 @@ class ProductionService
*/
private function outputSignature(Production $production): array
{
$signature = $production->outputs()
->get(['product_id', 'quantity'])
->map(fn ($o) => (int) $o->product_id.':'.(int) $o->quantity)
->all();
sort($signature);
// N-03 (Nachprüfung 08.09.2026): Stückzahlen je Produkt summieren — ein reiner
// Chargensplit (6 + 4 statt 10) ist keine Mengenänderung.
$totals = [];
foreach ($production->outputs()->get(['product_id', 'quantity']) as $o) {
$totals[(int) $o->product_id] = ($totals[(int) $o->product_id] ?? 0) + (int) $o->quantity;
}
ksort($totals);
return $signature;
return array_map(fn ($pid, $qty) => $pid.':'.$qty, array_keys($totals), $totals);
}
/**
@ -537,17 +539,65 @@ class ProductionService
$consumed = $this->consumedByStockEntry($entries->pluck('id')->all(), $excludeProductionId);
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));
$entries = $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;
})
return $entry;
});
return $this->applyUnboundDisposals($entries, $ingredientId)
->filter(fn (StockEntry $entry) => (float) $entry->getAttribute('remaining_quantity') > 0.0)
->values();
}
/**
* N-05 (Nachprüfung 08.09.2026): Ausschuss **ohne** Chargenbezug (Altbestand vor F-09)
* mindert Gesamt- und Standortbestand, aber keine einzelne Charge. Für die
* Verfügbarkeit wird er hier je Standort FEFO auf die Chargen angerechnet — dieselbe
* Regel, nach der neuer ungebundener Ausschuss gebucht wird. Es wird nichts
* geschrieben; die Altbuchungen bleiben unverändert.
*
* @param Collection<int, StockEntry> $entries FEFO-sortiert, mit Attribut remaining_quantity
* @return Collection<int, StockEntry>
*/
public function applyUnboundDisposals(Collection $entries, int $ingredientId): Collection
{
if ($entries->isEmpty()) {
return $entries;
}
$unbound = StockDisposal::query()
->where('disposal_type', 'ingredient')
->where('ingredient_id', $ingredientId)
->whereNull('stock_entry_id')
->whereIn('location_id', $entries->pluck('location_id')->unique()->all())
->selectRaw('location_id, SUM(quantity) as qty')
->groupBy('location_id')
->pluck('qty', 'location_id');
foreach ($unbound as $locationId => $qty) {
$needed = round((float) $qty, 2);
foreach ($entries as $entry) {
if ($needed <= 0) {
break;
}
if ((int) $entry->location_id !== (int) $locationId) {
continue;
}
$remaining = (float) $entry->getAttribute('remaining_quantity');
if ($remaining <= 0) {
continue;
}
$take = round(min($remaining, $needed), 2);
$entry->setAttribute('remaining_quantity', round($remaining - $take, 2));
$needed = round($needed - $take, 2);
}
}
return $entries;
}
public function stockEntryLabel(StockEntry $entry): string
{
$parts = [];
@ -788,7 +838,10 @@ class ProductionService
{
return array_values(array_filter($ingredientLines, function (array $line): bool {
return (int) ($line['ingredient_id'] ?? 0) > 0
&& ((int) ($line['stock_entry_id'] ?? 0) > 0 || trim((string) ($line['batch_key'] ?? '')) !== '');
&& ((int) ($line['stock_entry_id'] ?? 0) > 0
|| trim((string) ($line['batch_key'] ?? '')) !== ''
// N-01: Ist-Menge ohne Charge ebenfalls sichern.
|| $this->parseQuantity($line['quantity_used'] ?? null) > 0);
}));
}

View file

@ -190,3 +190,41 @@ test('F-09: ausschuss ohne charge wird FEFO auf die chargen des lagerorts vertei
expect($inventory->remainingByIngredient([$ing->id])[$ing->id])->toBe(0.0)
->and(app(ProductionService::class)->availableStockEntriesForIngredient($ing->id, $loc->id))->toHaveCount(0);
});
test('N-05: alt-ausschuss ohne chargenbezug wird in der chargenverfügbarkeit FEFO je standort angerechnet', function () {
$user = cdcUser();
$supplier = Supplier::factory()->create();
$loc = Location::factory()->create();
$other = Location::factory()->create();
$ing = cdcIngredient('N05-Alt');
$old = cdcStock($ing, $loc, $supplier, $user, ['received_quantity' => 300, 'batch_number' => 'N05-ALT', 'best_before' => '2027-01-01']);
$new = cdcStock($ing, $loc, $supplier, $user, ['received_quantity' => 700, 'batch_number' => 'N05-NEU', 'best_before' => '2029-01-01']);
$elsewhere = cdcStock($ing, $other, $supplier, $user, ['received_quantity' => 500, 'batch_number' => 'N05-ANDERER']);
// Altbuchung direkt (ohne Controller/FEFO-Verteilung): 400 g ohne Charge am Standort loc.
cdcDisposal($ing, $loc, 400);
$inventory = app(InventoryService::class);
$service = app(ProductionService::class);
$available = $service->availableStockEntriesForIngredient($ing->id, $loc->id);
expect($inventory->remainingByIngredient([$ing->id])[$ing->id])->toBe(1100.0)
->and($available)->toHaveCount(1)
->and($available->first()->id)->toBe($new->id)
->and((float) $available->first()->getAttribute('remaining_quantity'))->toBe(600.0)
->and($inventory->chargesWithRemainingForIngredient($ing->id)->sum('remaining_quantity'))->toEqual(1100)
->and((float) $service->availableStockEntriesForIngredient($ing->id, $other->id)->first()->getAttribute('remaining_quantity'))->toBe(500.0);
// D-07 des Erstberichts: gesamter Eingang ohne Charge entsorgt → keine Charge mehr verfügbar.
cdcDisposal($ing, $loc, 600);
expect($inventory->remainingByIngredient([$ing->id])[$ing->id])->toBe(500.0)
->and($service->availableStockEntriesForIngredient($ing->id, $loc->id))->toHaveCount(0);
// Chargen-Ausschuss über den Rest der Charge hinaus wird abgelehnt (nutzt dieselbe Verfügbarkeit).
$this->actingAs($user, 'user');
$this->from(route('admin.inventory.stock-disposals.create'))
->post(route('admin.inventory.stock-disposals.store'), [
'disposal_type' => 'ingredient', 'ingredient_id' => $ing->id, 'stock_entry_id' => $new->id, 'location_id' => $loc->id,
'quantity' => '1', 'reason' => 'Verfall / MHD überschritten', 'disposed_at' => '2026-09-01',
])->assertSessionHasErrors('quantity');
});

View file

@ -215,6 +215,16 @@ test('F-05: notizkorrektur behält den historischen verpackungs-snapshot, mengen
$production = $service->updateProduction($production->fresh(), $head + ['quantity' => 3], [], $user->id,
[['product_id' => $product->id, 'quantity' => 3, 'batch_number' => 'F05']]);
expect($production->productionPackagings->sum('quantity_used'))->toBe(6);
// N-03: Chargensplit bei gleicher Gesamtstückzahl (3 → 2 + 1) ist keine Mengenänderung — Snapshot bleibt,
// auch wenn die Stückliste inzwischen wieder anders ist.
$product->packagings()->sync([$pk->id => ['quantity' => 5, 'pos' => 0]]);
$production = $service->updateProduction($production->fresh(), $head + ['quantity' => 3], [], $user->id, [
['product_id' => $product->id, 'quantity' => 2, 'batch_number' => 'F05-A'],
['product_id' => $product->id, 'quantity' => 1, 'batch_number' => 'F05-B'],
]);
expect($production->productionPackagings->sum('quantity_used'))->toBe(6)
->and($production->outputs)->toHaveCount(2);
});
test('AP-47: produktions-auswahl zeigt nur produktionsrelevante produkte', function () {