From db27f040e63707a0534b30d146fee58beeecccf2 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Tue, 8 Sep 2026 11:01:13 +0000 Subject: [PATCH] =?UTF-8?q?Warenwirtschaft:=20F-09=20=E2=80=94=20Ausschuss?= =?UTF-8?q?=20ohne=20Charge=20standort-=20und=20chargenkonsistent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ohne Charge wird gegen den Restbestand des gewählten Lagerorts geprüft und die Menge FEFO auf die dort verfügbaren Chargen verteilt (je Charge gebucht, in einer Transaktion) — wie der Produktionsverbrauch. Gesamt-, Standort- und Chargenbestand stimmen damit überein. Co-Authored-By: Claude Fable 5.1 --- .../Inventory/StockDisposalController.php | 49 ++++++++++++++++- .../Inventory/StoreStockDisposalRequest.php | 9 ++-- .../Feature/ChargeDisposalConsistencyTest.php | 54 +++++++++++++++++++ 3 files changed, 107 insertions(+), 5 deletions(-) diff --git a/app/Http/Controllers/Admin/Inventory/StockDisposalController.php b/app/Http/Controllers/Admin/Inventory/StockDisposalController.php index 32f5169..b7d6a5f 100644 --- a/app/Http/Controllers/Admin/Inventory/StockDisposalController.php +++ b/app/Http/Controllers/Admin/Inventory/StockDisposalController.php @@ -10,9 +10,11 @@ use App\Models\Location; use App\Models\StockDisposal; use App\Models\StockEntry; use App\Services\InventoryService; +use App\Services\ProductionService; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; use Illuminate\View\View; class StockDisposalController extends Controller @@ -67,18 +69,61 @@ class StockDisposalController extends Controller ]); } - public function store(StoreStockDisposalRequest $request): RedirectResponse + public function store(StoreStockDisposalRequest $request, ProductionService $productionService): RedirectResponse { $data = $request->validatedPayload(); $data['user_id'] = (int) $request->user()->id; - StockDisposal::query()->create($data); + DB::transaction(function () use ($data, $productionService): void { + foreach ($this->splitAcrossBatches($data, $productionService) as $row) { + StockDisposal::query()->create($row); + } + }); \Session::flash('alert-save', '1'); return redirect()->route('admin.inventory.stock-disposals.index'); } + /** + * Prüfung 08.09.2026 F-09: Rohstoff-Ausschuss ohne bestimmte Charge wird — wie der + * Produktionsverbrauch (AP-27, FEFO) — auf die am Lagerort verfügbaren Chargen + * verteilt und je Charge gebucht. Sonst sinkt nur der Gesamtbestand, während die + * Chargen in Produktion/Ausschuss weiter als voll verfügbar angeboten würden. + * Ohne verfügbare Charge (Altbestand ohne Chargenbezug) bleibt die Buchung ungebunden. + * + * @param array $data + * @return array> + */ + private function splitAcrossBatches(array $data, ProductionService $productionService): array + { + if (($data['disposal_type'] ?? null) !== 'ingredient' || ! empty($data['stock_entry_id'])) { + return [$data]; + } + + $entries = $productionService->availableStockEntriesForIngredient((int) $data['ingredient_id'], (int) $data['location_id']); + if ($entries->isEmpty()) { + return [$data]; + } + + $rows = []; + $needed = round((float) $data['quantity'], 2); + $lastIndex = $entries->count() - 1; + foreach ($entries as $index => $entry) { + $capacity = (float) $entry->getAttribute('remaining_quantity'); + $take = $index === $lastIndex ? $needed : round(min($needed, $capacity), 2); + if ($take > 0) { + $rows[] = array_merge($data, ['stock_entry_id' => (int) $entry->id, 'quantity' => $take]); + } + $needed = round($needed - $take, 2); + if ($needed <= 0) { + break; + } + } + + return $rows; + } + /** * Anforderung 15.07.2026: Ausschuss-Buchungen löschbar (Soft-Delete). * Die Bestandsaggregationen schließen gelöschte Buchungen automatisch diff --git a/app/Http/Requests/Inventory/StoreStockDisposalRequest.php b/app/Http/Requests/Inventory/StoreStockDisposalRequest.php index bc26421..df96788 100644 --- a/app/Http/Requests/Inventory/StoreStockDisposalRequest.php +++ b/app/Http/Requests/Inventory/StoreStockDisposalRequest.php @@ -88,11 +88,14 @@ class StoreStockDisposalRequest extends FormRequest $ingredientId = (int) $this->input('ingredient_id'); if (! $this->filled('stock_entry_id')) { - if ($ingredientId > 0 && $quantity > 0) { + // Prüfung 08.09.2026 F-09: ohne Charge gegen den gewählten Lagerort prüfen, + // nicht gegen den globalen Bestand (sonst geht ein leerer Standort ins Minus). + $locationId = (int) $this->input('location_id'); + if ($ingredientId > 0 && $quantity > 0 && $locationId > 0) { $remaining = app(InventoryService::class) - ->remainingByIngredient([$ingredientId])[$ingredientId] ?? 0.0; + ->remainingByLocationForIngredient($ingredientId)[$locationId] ?? 0.0; if ($quantity > $remaining) { - $validator->errors()->add('quantity', __('Die Menge (:qty g) übersteigt den Restbestand (:rest g).', [ + $validator->errors()->add('quantity', __('Die Menge (:qty g) übersteigt den Restbestand am gewählten Lagerort (:rest g).', [ 'qty' => number_format($quantity, 2, ',', '.'), 'rest' => number_format(max(0, $remaining), 2, ',', '.'), ])); diff --git a/tests/Feature/ChargeDisposalConsistencyTest.php b/tests/Feature/ChargeDisposalConsistencyTest.php index 7318bd5..7d9ac04 100644 --- a/tests/Feature/ChargeDisposalConsistencyTest.php +++ b/tests/Feature/ChargeDisposalConsistencyTest.php @@ -136,3 +136,57 @@ test('standort-aggregation rechnet chargen-ausschuss am standort der charge ab', expect($byLocation[$locationA->id])->toBe(900.0); expect($byLocation)->not->toHaveKey($locationB->id); }); + +test('F-09: ausschuss ohne charge prüft den gewählten lagerort, nicht den globalen bestand', function () { + $user = cdcUser(); + $supplier = Supplier::factory()->create(); + $locA = Location::factory()->create(['name' => 'F09-A']); + $locB = Location::factory()->create(['name' => 'F09-B']); + $ing = cdcIngredient('F09-Standort'); + cdcStock($ing, $locA, $supplier, $user, ['received_quantity' => 1000, 'batch_number' => 'F09-1']); + + $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, 'location_id' => $locB->id, + 'quantity' => '500', 'reason' => 'Verfall / MHD überschritten', 'disposed_at' => '2026-09-01', + ])->assertSessionHasErrors('quantity'); + + expect(app(InventoryService::class)->remainingByLocationForIngredient($ing->id)[$locB->id] ?? 0.0)->toBe(0.0); +}); + +test('F-09: ausschuss ohne charge wird FEFO auf die chargen des lagerorts verteilt und reduziert deren verfügbarkeit', function () { + $user = cdcUser(); + $supplier = Supplier::factory()->create(); + $loc = Location::factory()->create(); + $ing = cdcIngredient('F09-FEFO'); + $old = cdcStock($ing, $loc, $supplier, $user, ['received_quantity' => 300, 'batch_number' => 'F09-ALT', 'best_before' => '2027-01-01']); + $new = cdcStock($ing, $loc, $supplier, $user, ['received_quantity' => 700, 'batch_number' => 'F09-NEU', 'best_before' => '2029-01-01']); + + $this->actingAs($user, 'user'); + $this->post(route('admin.inventory.stock-disposals.store'), [ + 'disposal_type' => 'ingredient', 'ingredient_id' => $ing->id, 'location_id' => $loc->id, + 'quantity' => '400', 'reason' => 'Verfall / MHD überschritten', 'disposed_at' => '2026-09-01', + ])->assertRedirect()->assertSessionHasNoErrors(); + + // 300 g aus der älteren Charge (FEFO), 100 g aus der neueren — beides chargenbezogen. + $this->assertDatabaseHas('stock_disposals', ['stock_entry_id' => $old->id, 'quantity' => 300.00]); + $this->assertDatabaseHas('stock_disposals', ['stock_entry_id' => $new->id, 'quantity' => 100.00]); + $this->assertDatabaseMissing('stock_disposals', ['ingredient_id' => $ing->id, 'stock_entry_id' => null]); + + $inventory = app(InventoryService::class); + $charges = app(ProductionService::class)->availableStockEntriesForIngredient($ing->id, $loc->id); + expect($inventory->remainingByIngredient([$ing->id])[$ing->id])->toBe(600.0) + ->and($charges)->toHaveCount(1) + ->and($charges->first()->id)->toBe($new->id) + ->and((float) $charges->first()->getAttribute('remaining_quantity'))->toBe(600.0) + ->and($inventory->chargesWithRemainingForIngredient($ing->id)->sum('remaining_quantity'))->toEqual(600); + + // Gesamten Rest ohne Charge entsorgen: global 0 und keine Charge mehr verfügbar (D-07). + $this->post(route('admin.inventory.stock-disposals.store'), [ + 'disposal_type' => 'ingredient', 'ingredient_id' => $ing->id, 'location_id' => $loc->id, + 'quantity' => '600', 'reason' => 'Verfall / MHD überschritten', 'disposed_at' => '2026-09-02', + ])->assertSessionHasNoErrors(); + expect($inventory->remainingByIngredient([$ing->id])[$ing->id])->toBe(0.0) + ->and(app(ProductionService::class)->availableStockEntriesForIngredient($ing->id, $loc->id))->toHaveCount(0); +});