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

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