From 84450385e356d5f7cab710c4fc792bf8e8f189ae Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Wed, 15 Jul 2026 12:42:46 +0000 Subject: [PATCH] =?UTF-8?q?Warenwirtschaft:=20OPT-6=20Bestandswahrheit=20?= =?UTF-8?q?=E2=80=94=20Ausschuss=20in=20Chargen-Restmengen=20(Audit=20H-1,?= =?UTF-8?q?=20M-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - consumedByStockEntry(): chargen-bezogener Ausschuss zählt jetzt als Verbrauch — entsorgte Chargen verschwinden aus Rohstoff-Detail, Produktions-FEFO/Dropdown und AP-27-Batch-Check. - Rohstoff-Detail: Kopfwert aus remainingByIngredient() (identisch zur Index-Seite, inkl. Ausschuss ohne Chargenbezug). - Aggregationen iterieren über die Key-Vereinigung (received ∪ consumed ∪ disposed) — negative Bestände werden nicht mehr als 0 maskiert. - Standort-Aggregation: Chargen-Ausschuss zählt am Standort der Charge (COALESCE), nicht am frei gewählten Formular-Standort. Tests: neuer ChargeDisposalConsistencyTest (5). Suite: 219 grün, 795 Assertions. Co-Authored-By: Claude Fable 5 --- .../Inventory/RawMaterialStockController.php | 4 +- app/Services/InventoryService.php | 46 ++++-- app/Services/ProductionService.php | 18 ++- .../Feature/ChargeDisposalConsistencyTest.php | 138 ++++++++++++++++++ 4 files changed, 193 insertions(+), 13 deletions(-) create mode 100644 tests/Feature/ChargeDisposalConsistencyTest.php diff --git a/app/Http/Controllers/Admin/Inventory/RawMaterialStockController.php b/app/Http/Controllers/Admin/Inventory/RawMaterialStockController.php index 1c21f55..57dee78 100644 --- a/app/Http/Controllers/Admin/Inventory/RawMaterialStockController.php +++ b/app/Http/Controllers/Admin/Inventory/RawMaterialStockController.php @@ -77,7 +77,9 @@ class RawMaterialStockController extends Controller // AP-27: Chargen je batch_number zusammengeführt (jeder Einkauf bleibt eigener stock_entry). $chargeGroups = $this->inventoryService->chargeGroupsForIngredient($ingredient->id); - $remaining = round((float) $chargeGroups->sum('remaining'), 2); + // Audit H-1: Kopfwert aus der Gesamt-Aggregation (zieht auch Ausschuss + // ohne Chargenbezug ab) — identisch zur Index-Seite, nicht Chargen-Summe. + $remaining = $this->inventoryService->remainingByIngredient([$ingredient->id])[$ingredient->id] ?? 0.0; $remainingByLocation = $this->inventoryService->remainingByLocationForIngredient($ingredient->id); diff --git a/app/Services/InventoryService.php b/app/Services/InventoryService.php index 9ad7300..e87849b 100644 --- a/app/Services/InventoryService.php +++ b/app/Services/InventoryService.php @@ -54,9 +54,18 @@ class InventoryService $disposed = $this->disposedByIngredient($ingredientIds); + // Audit M-5: über die Vereinigung aller Keys iterieren — Rohstoffe mit + // Verbrauch/Ausschuss, aber ohne Eingang, würden sonst fehlen und + // negative Bestände als 0 maskiert. + $ids = collect($received->keys()) + ->merge($consumed->keys()) + ->merge(array_keys($disposed)) + ->map(fn ($id) => (int) $id) + ->unique(); + $result = []; - foreach ($received as $id => $qty) { - $result[(int) $id] = round((float) $qty - (float) ($consumed[$id] ?? 0) - (float) ($disposed[(int) $id] ?? 0), 2); + foreach ($ids as $id) { + $result[$id] = round((float) ($received[$id] ?? 0) - (float) ($consumed[$id] ?? 0) - (float) ($disposed[$id] ?? 0), 2); } return $result; @@ -110,16 +119,25 @@ class InventoryService ->groupBy('stock_entries.location_id') ->pluck('qty', 'location_id'); + // Audit M-5: chargen-bezogener Ausschuss zählt am Standort der Charge + // (COALESCE), nicht am frei gewählten Disposal-Standort. $disposed = StockDisposal::query() - ->where('disposal_type', 'ingredient') - ->where('ingredient_id', $ingredientId) - ->selectRaw('location_id, SUM(quantity) as qty') - ->groupBy('location_id') - ->pluck('qty', 'location_id'); + ->leftJoin('stock_entries', 'stock_entries.id', '=', 'stock_disposals.stock_entry_id') + ->where('stock_disposals.disposal_type', 'ingredient') + ->where('stock_disposals.ingredient_id', $ingredientId) + ->selectRaw('COALESCE(stock_entries.location_id, stock_disposals.location_id) as loc_id, SUM(stock_disposals.quantity) as qty') + ->groupByRaw('COALESCE(stock_entries.location_id, stock_disposals.location_id)') + ->pluck('qty', 'loc_id'); + + $ids = collect($received->keys()) + ->merge($consumed->keys()) + ->merge($disposed->keys()) + ->map(fn ($id) => (int) $id) + ->unique(); $result = []; - foreach ($received as $locationId => $qty) { - $result[(int) $locationId] = round((float) $qty - (float) ($consumed[$locationId] ?? 0) - (float) ($disposed[$locationId] ?? 0), 2); + foreach ($ids as $locationId) { + $result[$locationId] = round((float) ($received[$locationId] ?? 0) - (float) ($consumed[$locationId] ?? 0) - (float) ($disposed[$locationId] ?? 0), 2); } return $result; @@ -152,9 +170,15 @@ class InventoryService $disposed = $this->disposedByPackagingItem($packagingItemIds); + $ids = collect($received->keys()) + ->merge($consumed->keys()) + ->merge(array_keys($disposed)) + ->map(fn ($id) => (int) $id) + ->unique(); + $result = []; - foreach ($received as $id => $qty) { - $result[(int) $id] = round((float) $qty - (float) ($consumed[$id] ?? 0) - (float) ($disposed[(int) $id] ?? 0), 2); + foreach ($ids as $id) { + $result[$id] = round((float) ($received[$id] ?? 0) - (float) ($consumed[$id] ?? 0) - (float) ($disposed[$id] ?? 0), 2); } return $result; diff --git a/app/Services/ProductionService.php b/app/Services/ProductionService.php index 9459f53..602d0b1 100644 --- a/app/Services/ProductionService.php +++ b/app/Services/ProductionService.php @@ -5,6 +5,7 @@ namespace App\Services; use App\Models\Product; use App\Models\Production; use App\Models\ProductionIngredient; +use App\Models\StockDisposal; use App\Models\StockEntry; use Carbon\Carbon; use Illuminate\Support\Collection; @@ -340,7 +341,10 @@ class ProductionService } /** - * Verbrauchte Menge je Wareneingang (über alle Produktionen), optional ohne eine bestimmte Produktion. + * Verbrauchte Menge je Wareneingang: Produktionsverbrauch **plus** chargen- + * bezogener Ausschuss (Audit H-1 — vorher zählte nur die Produktion, wodurch + * entsorgte Chargen weiter als verfügbar galten). Optional ohne eine + * bestimmte Produktion. * * @param array $stockEntryIds * @return array @@ -365,6 +369,18 @@ class ProductionService $result[(int) $stockEntryId] = (float) $used; } + $disposed = StockDisposal::query() + ->where('disposal_type', 'ingredient') + ->whereNotNull('stock_entry_id') + ->whereIn('stock_entry_id', $stockEntryIds) + ->selectRaw('stock_entry_id, SUM(quantity) as used') + ->groupBy('stock_entry_id') + ->pluck('used', 'stock_entry_id'); + + foreach ($disposed as $stockEntryId => $used) { + $result[(int) $stockEntryId] = ($result[(int) $stockEntryId] ?? 0.0) + (float) $used; + } + return $result; } diff --git a/tests/Feature/ChargeDisposalConsistencyTest.php b/tests/Feature/ChargeDisposalConsistencyTest.php new file mode 100644 index 0000000..7318bd5 --- /dev/null +++ b/tests/Feature/ChargeDisposalConsistencyTest.php @@ -0,0 +1,138 @@ +create([ + 'email' => uniqid('cdc_', true).'@test.example', + 'password' => bcrypt('password'), + ]); + $user->forceFill(['admin' => 7, 'confirmed' => true, 'active' => true, 'wizard' => 100, 'blocked' => false])->save(); + + return $user->fresh(); +} + +function cdcIngredient(string $name): Ingredient +{ + return Ingredient::query()->create([ + 'name' => $name, 'trans_name' => '', 'inci' => $name, 'trans_inci' => '', + 'effect' => '', 'trans_effect' => '', 'active' => true, 'pos' => 0, + ]); +} + +function cdcStock(Ingredient $ing, Location $location, Supplier $supplier, User $user, array $overrides = []): StockEntry +{ + return StockEntry::query()->create(array_merge([ + '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' => 1000, 'status' => 'received', + 'received_by' => $user->id, 'received_at' => '2026-01-15', + 'received_quantity' => 1000, 'batch_number' => 'CDC-B1', 'best_before' => '2030-12-31', + ], $overrides)); +} + +function cdcDisposal(Ingredient $ing, Location $location, float $qty, ?int $stockEntryId = null): StockDisposal +{ + return StockDisposal::query()->create([ + 'disposal_type' => 'ingredient', 'ingredient_id' => $ing->id, + 'stock_entry_id' => $stockEntryId, 'location_id' => $location->id, + 'quantity' => $qty, 'unit' => 'gram', 'reason' => 'Test', 'disposed_at' => '2026-07-01', + ]); +} + +test('chargen-ausschuss reduziert chargen-restmenge in detail, produktion und batch-check', function () { + $user = cdcUser(); + $this->actingAs($user, 'user'); + $ing = cdcIngredient('CDC-Voll'); + $location = Location::factory()->create(); + $supplier = Supplier::factory()->create(); + $entry = cdcStock($ing, $location, $supplier, $user); + + cdcDisposal($ing, $location, 1000, $entry->id); + + // Detail: Charge vollständig entsorgt => keine verfügbare Charge mehr. + expect(app(InventoryService::class)->chargesWithRemainingForIngredient($ing->id))->toHaveCount(0); + + // Produktion: Charge erscheint nicht mehr im FEFO-Angebot. + expect(app(ProductionService::class)->availableStockEntriesForIngredient($ing->id, $location->id))->toHaveCount(0); + + // Batch-Check (AP-27): remaining = 0. + $res = $this->getJson(route('admin.inventory.api.stock-entries.batch-check', [ + 'ingredient_id' => $ing->id, 'batch_number' => 'CDC-B1', + ])); + $res->assertOk(); + expect((float) $res->json('remaining'))->toBe(0.0); +}); + +test('teil-ausschuss je charge: rest sinkt entsprechend', function () { + $user = cdcUser(); + $ing = cdcIngredient('CDC-Teil'); + $location = Location::factory()->create(); + $supplier = Supplier::factory()->create(); + $entry = cdcStock($ing, $location, $supplier, $user); + + cdcDisposal($ing, $location, 300, $entry->id); + + $charges = app(InventoryService::class)->chargesWithRemainingForIngredient($ing->id); + expect($charges)->toHaveCount(1); + expect((float) $charges->first()->getAttribute('remaining_quantity'))->toBe(700.0); +}); + +test('detail-kopfwert entspricht der index-aggregation auch bei ausschuss ohne chargenbezug', function () { + $user = cdcUser(); + $this->actingAs($user, 'user'); + $ing = cdcIngredient('CDC-Kopf'); + $location = Location::factory()->create(); + $supplier = Supplier::factory()->create(); + cdcStock($ing, $location, $supplier, $user); + + cdcDisposal($ing, $location, 400, null); // ohne Chargenbezug + + $indexRemaining = app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id]; + expect($indexRemaining)->toBe(600.0); + + $response = $this->get(route('admin.inventory.raw-material-stock.show', $ing)); + $response->assertOk(); + expect($response->viewData('remaining'))->toBe(600.0); +}); + +test('negativer bestand wird nicht mehr als 0 maskiert (key-vereinigung)', function () { + $user = cdcUser(); + $ing = cdcIngredient('CDC-Negativ'); + $location = Location::factory()->create(); + + // Ausschuss ohne jeglichen Wareneingang. + cdcDisposal($ing, $location, 250, null); + + $remaining = app(InventoryService::class)->remainingByIngredient([$ing->id]); + expect($remaining[$ing->id] ?? null)->toBe(-250.0); +}); + +test('standort-aggregation rechnet chargen-ausschuss am standort der charge ab', function () { + $user = cdcUser(); + $ing = cdcIngredient('CDC-Standort'); + $locationA = Location::factory()->create(['name' => 'A']); + $locationB = Location::factory()->create(['name' => 'B']); + $supplier = Supplier::factory()->create(); + $entry = cdcStock($ing, $locationA, $supplier, $user); + + // Ausschuss der Charge (liegt an A), aber im Formular mit Standort B gebucht. + cdcDisposal($ing, $locationB, 100, $entry->id); + + $byLocation = app(InventoryService::class)->remainingByLocationForIngredient($ing->id); + expect($byLocation[$locationA->id])->toBe(900.0); + expect($byLocation)->not->toHaveKey($locationB->id); +});