From 313c6277ce77227e2d6c18fd228bb09d471055f7 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Tue, 8 Sep 2026 11:48:02 +0000 Subject: [PATCH] =?UTF-8?q?Warenwirtschaft:=20N-01/N-03/N-05=20=E2=80=94?= =?UTF-8?q?=20Entwurfsmenge=20ohne=20Charge,=20Snapshot-Signatur=20je=20Pr?= =?UTF-8?q?odukt,=20Alt-Ausschuss=20in=20Chargenverf=C3=BCgbarkeit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../Inventory/StockDisposalController.php | 4 +- .../Inventory/StoreProductionRequest.php | 12 ++- .../Inventory/StoreStockDisposalRequest.php | 6 +- app/Models/ProductionIngredient.php | 4 + app/Services/InventoryService.php | 8 +- app/Services/ProductionService.php | 79 ++++++++++++++++--- .../Feature/ChargeDisposalConsistencyTest.php | 38 +++++++++ tests/Feature/ProductionScalingTest.php | 10 +++ 8 files changed, 138 insertions(+), 23 deletions(-) diff --git a/app/Http/Controllers/Admin/Inventory/StockDisposalController.php b/app/Http/Controllers/Admin/Inventory/StockDisposalController.php index 5d8900e..bf9f14d 100644 --- a/app/Http/Controllers/Admin/Inventory/StockDisposalController.php +++ b/app/Http/Controllers/Admin/Inventory/StockDisposalController.php @@ -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() diff --git a/app/Http/Requests/Inventory/StoreProductionRequest.php b/app/Http/Requests/Inventory/StoreProductionRequest.php index f60a1cd..d3430dc 100644 --- a/app/Http/Requests/Inventory/StoreProductionRequest.php +++ b/app/Http/Requests/Inventory/StoreProductionRequest.php @@ -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; diff --git a/app/Http/Requests/Inventory/StoreStockDisposalRequest.php b/app/Http/Requests/Inventory/StoreStockDisposalRequest.php index df96788..5429935 100644 --- a/app/Http/Requests/Inventory/StoreStockDisposalRequest.php +++ b/app/Http/Requests/Inventory/StoreStockDisposalRequest.php @@ -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, ',', '.'), diff --git a/app/Models/ProductionIngredient.php b/app/Models/ProductionIngredient.php index dfcdfa3..8988eb9 100644 --- a/app/Models/ProductionIngredient.php +++ b/app/Models/ProductionIngredient.php @@ -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; } diff --git a/app/Services/InventoryService.php b/app/Services/InventoryService.php index 87df293..501e694 100644 --- a/app/Services/InventoryService.php +++ b/app/Services/InventoryService.php @@ -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(); } diff --git a/app/Services/ProductionService.php b/app/Services/ProductionService.php index 81c244a..45eba38 100644 --- a/app/Services/ProductionService.php +++ b/app/Services/ProductionService.php @@ -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 $entries FEFO-sortiert, mit Attribut remaining_quantity + * @return Collection + */ + 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); })); } diff --git a/tests/Feature/ChargeDisposalConsistencyTest.php b/tests/Feature/ChargeDisposalConsistencyTest.php index 7d9ac04..928e781 100644 --- a/tests/Feature/ChargeDisposalConsistencyTest.php +++ b/tests/Feature/ChargeDisposalConsistencyTest.php @@ -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'); +}); diff --git a/tests/Feature/ProductionScalingTest.php b/tests/Feature/ProductionScalingTest.php index 062ca10..203d4c9 100644 --- a/tests/Feature/ProductionScalingTest.php +++ b/tests/Feature/ProductionScalingTest.php @@ -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 () {