From 731b2461e2cae722d0bfb2e40d5b30fef6b0ada2 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Tue, 8 Sep 2026 11:09:53 +0000 Subject: [PATCH] =?UTF-8?q?Warenwirtschaft:=20F-16=20=E2=80=94=20fachliche?= =?UTF-8?q?=20Vorbedingungen=20innerhalb=20der=20Sperre=20pr=C3=BCfen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Statusservice: Vorbedingung (Ausgangsstatus, Zahlungsfreigabe) wird nach lockForUpdate erneut geprüft (Board übergibt sie als Closure). - Wareneingang: Transaktion + Sperre, „offen“ wird innerhalb der Sperre geprüft. - Ausschuss: betroffene Wareneingänge sperren und Restbestand in der Transaktion erneut prüfen. Co-Authored-By: Claude Fable 5.1 --- .../Inventory/OrderFulfillmentController.php | 44 +++++++++----- .../Inventory/StockDisposalController.php | 59 +++++++++++++++++-- .../Admin/Inventory/StockEntryController.php | 14 +++-- app/Repositories/StockEntryRepository.php | 30 +++++++--- app/Services/OrderStatusService.php | 18 +++++- tests/Feature/IngredientDensityTest.php | 24 ++++++++ tests/Feature/OrderFulfillmentTest.php | 17 ++++++ 7 files changed, 170 insertions(+), 36 deletions(-) diff --git a/app/Http/Controllers/Admin/Inventory/OrderFulfillmentController.php b/app/Http/Controllers/Admin/Inventory/OrderFulfillmentController.php index 451669b..cb19f22 100644 --- a/app/Http/Controllers/Admin/Inventory/OrderFulfillmentController.php +++ b/app/Http/Controllers/Admin/Inventory/OrderFulfillmentController.php @@ -12,6 +12,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Response; use Illuminate\Support\Facades\Storage; use Illuminate\Validation\Rule; +use Illuminate\Validation\ValidationException; /** * AP-33a: Abwicklungs-Board („Bestellungen → Abwicklung") — kompakte Tagesübersicht @@ -85,29 +86,40 @@ class OrderFulfillmentController extends Controller 'shipped' => ['required', 'integer', Rule::in([1, 2])], ]); - $order = ShoppingOrder::query()->findOrFail($id); - if (! in_array((int) $order->shipped, [0, 1], true)) { - session()->flash('alert-error', __('Bestellung ist nicht mehr in der Abwicklung (Status: :status).', ['status' => $order->getShippedType()])); + $shipped = (int) $request->input('shipped'); + $backUrl = route('admin.inventory.orders.index', array_filter(['filter' => $request->input('back_filter')])); - return redirect()->route('admin.inventory.orders.index'); + // Fachliche Vorbedingungen — Prüfung 08.09.2026 F-16: werden vom Statusservice + // innerhalb der Zeilensperre erneut geprüft, nicht nur hier vorab. + $precondition = function (ShoppingOrder $order) use ($shipped): ?string { + if (! in_array((int) $order->shipped, [0, 1], true)) { + return __('Bestellung ist nicht mehr in der Abwicklung (Status: :status).', ['status' => $order->getShippedType()]); + } + // Prüfung 08.09.2026 F-10: „versendet“ (Bestandsabzug) nur für versandfertige + // Bestellungen — Zahlung eingegangen bzw. Rechnung/API (Kundenentscheid 16.07.2026, + // isReadyToShip). Konfektionieren bleibt auch vor Zahlungseingang möglich. + if ($shipped === 2 && ! $order->isReadyToShip()) { + return __('Bestellung #:id wartet auf Zahlung und kann noch nicht als versendet gebucht werden.', ['id' => $order->id]); + } + + return null; + }; + + ShoppingOrder::query()->findOrFail($id); + + try { + app(OrderStatusService::class)->setShipped($id, $shipped, auth()->id(), lockStatus: true, precondition: $precondition); + } catch (ValidationException $e) { + session()->flash('alert-error', $e->errors()['shipped'][0] ?? __('Statuswechsel nicht möglich.')); + + return redirect()->to($backUrl); } - // Prüfung 08.09.2026 F-10: „versendet“ (Bestandsabzug) nur für versandfertige - // Bestellungen — Zahlung eingegangen bzw. Rechnung/API (Kundenentscheid 16.07.2026, - // isReadyToShip). Konfektionieren bleibt auch vor Zahlungseingang möglich. - if ((int) $request->input('shipped') === 2 && ! $order->isReadyToShip()) { - session()->flash('alert-error', __('Bestellung #:id wartet auf Zahlung und kann noch nicht als versendet gebucht werden.', ['id' => $id])); - - return redirect()->route('admin.inventory.orders.index', array_filter(['filter' => $request->input('back_filter')])); - } - - app(OrderStatusService::class)->setShipped($id, (int) $request->input('shipped'), auth()->id(), lockStatus: true); - session()->flash('alert-success', (int) $request->input('shipped') === 2 ? __('Bestellung #:id als versendet gebucht — Produktbestand wurde abgezogen.', ['id' => $id]) : __('Bestellung #:id als konfektioniert markiert.', ['id' => $id])); - return redirect()->route('admin.inventory.orders.index', array_filter(['filter' => $request->input('back_filter')])); + return redirect()->to($backUrl); } /** diff --git a/app/Http/Controllers/Admin/Inventory/StockDisposalController.php b/app/Http/Controllers/Admin/Inventory/StockDisposalController.php index b7d6a5f..5d8900e 100644 --- a/app/Http/Controllers/Admin/Inventory/StockDisposalController.php +++ b/app/Http/Controllers/Admin/Inventory/StockDisposalController.php @@ -15,6 +15,7 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; +use Illuminate\Validation\ValidationException; use Illuminate\View\View; class StockDisposalController extends Controller @@ -66,6 +67,8 @@ class StockDisposalController extends Controller 'locations' => Location::query()->where('active', true)->orderBy('name')->get(), 'reasons' => $this->reasons(), 'prefill' => $prefill, + // AP-50/F-17: Köln auch beim Ausschuss vorbelegen. + 'defaultLocationId' => Location::defaultId(), ]); } @@ -74,17 +77,63 @@ class StockDisposalController extends Controller $data = $request->validatedPayload(); $data['user_id'] = (int) $request->user()->id; - DB::transaction(function () use ($data, $productionService): void { - foreach ($this->splitAcrossBatches($data, $productionService) as $row) { - StockDisposal::query()->create($row); - } - }); + try { + DB::transaction(function () use ($data, $productionService): void { + $this->assertStillAvailable($data, $productionService); + foreach ($this->splitAcrossBatches($data, $productionService) as $row) { + StockDisposal::query()->create($row); + } + }); + } catch (ValidationException $e) { + return redirect()->back()->withInput()->withErrors($e->errors()); + } \Session::flash('alert-save', '1'); return redirect()->route('admin.inventory.stock-disposals.index'); } + /** + * Prüfung 08.09.2026 F-16: Bestandsprüfung innerhalb der Transaktion wiederholen, + * nachdem die betroffenen Wareneingänge gesperrt wurden — die FormRequest-Prüfung + * allein lässt zwei parallele Entnahmen denselben Rest bestehen. + * + * @param array $data + * + * @throws ValidationException + */ + private function assertStillAvailable(array $data, ProductionService $productionService): void + { + if (($data['disposal_type'] ?? null) !== 'ingredient') { + return; + } + + $ingredientId = (int) $data['ingredient_id']; + $quantity = round((float) $data['quantity'], 2); + + 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); + } else { + // Alle Eingänge des Rohstoffs am Standort sperren, dann Standortrest neu berechnen. + StockEntry::query()->lockForUpdate() + ->where('entry_type', 'ingredient')->where('ingredient_id', $ingredientId) + ->where('location_id', (int) $data['location_id']) + ->get(); + $remaining = round((float) ($this->inventoryService->remainingByLocationForIngredient($ingredientId)[(int) $data['location_id']] ?? 0.0), 2); + } + + if ($quantity > $remaining) { + throw ValidationException::withMessages([ + 'quantity' => __('Die Menge (:qty g) übersteigt den Restbestand (:rest g) — Bestand hat sich zwischenzeitlich geändert.', [ + 'qty' => number_format($quantity, 2, ',', '.'), + 'rest' => number_format(max(0, $remaining), 2, ',', '.'), + ]), + ]); + } + } + /** * 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 diff --git a/app/Http/Controllers/Admin/Inventory/StockEntryController.php b/app/Http/Controllers/Admin/Inventory/StockEntryController.php index bfe6fc2..dfcc420 100644 --- a/app/Http/Controllers/Admin/Inventory/StockEntryController.php +++ b/app/Http/Controllers/Admin/Inventory/StockEntryController.php @@ -19,6 +19,7 @@ use App\Services\InventoryService; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Validation\ValidationException; use Illuminate\View\View; class StockEntryController extends Controller @@ -46,9 +47,8 @@ class StockEntryController extends Controller $model = new StockEntry([ 'ordered_at' => now()->toDateString(), 'entry_type' => 'ingredient', - // AP-50: Köln als Standard-Lagerort vorbelegen (wie in der Produktion). - 'location_id' => Location::query()->where('name', 'like', '%öln%')->value('id') - ?? Location::query()->where('active', true)->orderBy('name')->value('id'), + // AP-50/F-17: Köln (aktiv) als Standard-Lagerort — zentral in Location::defaultId(). + 'location_id' => Location::defaultId(), ]); $ingredientId = (int) $request->query('ingredient_id'); @@ -213,7 +213,13 @@ class StockEntryController extends Controller $data['best_before'] = null; } - $this->stockEntryRepository->receive($stockEntry, $data); + try { + $this->stockEntryRepository->receive($stockEntry, $data); + } catch (ValidationException $e) { + \Session::flash('alert-error', $e->errors()['status'][0] ?? __('Eintrag nicht mehr offen.')); + + return redirect()->route('admin.inventory.stock-entries.show', $stockEntry); + } \Session::flash('alert-save', '1'); diff --git a/app/Repositories/StockEntryRepository.php b/app/Repositories/StockEntryRepository.php index d925a7a..652bd71 100644 --- a/app/Repositories/StockEntryRepository.php +++ b/app/Repositories/StockEntryRepository.php @@ -6,6 +6,8 @@ use App\Models\Ingredient; use App\Models\StockEntry; use App\Models\TaxRate; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Facades\DB; +use Illuminate\Validation\ValidationException; class StockEntryRepository { @@ -133,18 +135,28 @@ class StockEntryRepository */ public function receive(StockEntry $stockEntry, array $data): StockEntry { - $data['status'] = 'received'; - $data['received_by'] = auth()->id(); + return DB::transaction(function () use ($stockEntry, $data) { + // Prüfung 08.09.2026 F-16: Zeile sperren und „offen“ innerhalb der Sperre erneut + // prüfen — zwei parallele Wareneingänge schreiben sonst nacheinander verschiedene + // Mengen auf denselben Eintrag. + $locked = StockEntry::query()->lockForUpdate()->findOrFail($stockEntry->id); + if (! $locked->isPending()) { + throw ValidationException::withMessages(['status' => __('Eintrag nicht mehr offen.')]); + } - // AP-42: Wurde in ml eingekauft, wird auch die gelieferte Menge in ml erfasst - // und über die (beim Bestellen gespeicherte) Dichte in Gramm umgerechnet. - if ($stockEntry->purchase_unit === 'ml' && $stockEntry->density_used !== null && isset($data['received_quantity'])) { - $data['received_quantity'] = round((float) $data['received_quantity'] * (float) $stockEntry->density_used, 2); - } + $data['status'] = 'received'; + $data['received_by'] = auth()->id(); - $stockEntry->update($data); + // AP-42: Wurde in ml eingekauft, wird auch die gelieferte Menge in ml erfasst + // und über die (beim Bestellen gespeicherte) Dichte in Gramm umgerechnet. + if ($locked->purchase_unit === 'ml' && $locked->density_used !== null && isset($data['received_quantity'])) { + $data['received_quantity'] = round((float) $data['received_quantity'] * (float) $locked->density_used, 2); + } - return $stockEntry->fresh(); + $locked->update($data); + + return $locked->fresh(); + }); } /** diff --git a/app/Services/OrderStatusService.php b/app/Services/OrderStatusService.php index 83a1b63..36613c0 100644 --- a/app/Services/OrderStatusService.php +++ b/app/Services/OrderStatusService.php @@ -5,6 +5,7 @@ namespace App\Services; use App\Models\Setting; use App\Models\ShoppingOrder; use Illuminate\Support\Facades\DB; +use Illuminate\Validation\ValidationException; /** * AP-33a: Zentraler Versand-Statuswechsel einer Bestellung — extrahiert aus @@ -14,10 +15,23 @@ use Illuminate\Support\Facades\DB; */ class OrderStatusService { - public function setShipped(int $orderId, int $shipped, ?int $userId = null, bool $lockStatus = false): ShoppingOrder + /** + * @param (\Closure(ShoppingOrder): ?string)|null $precondition Prüfung 08.09.2026 F-16: fachliche + * Vorbedingung, die innerhalb der Sperre + * erneut geprüft wird (liefert Fehlertext + * oder null). Sonst können zwei Requests + * denselben Ausgangsstatus lesen und sich + * gegenseitig überschreiben. + * + * @throws ValidationException + */ + public function setShipped(int $orderId, int $shipped, ?int $userId = null, bool $lockStatus = false, ?\Closure $precondition = null): ShoppingOrder { - return DB::transaction(function () use ($orderId, $shipped, $userId, $lockStatus) { + return DB::transaction(function () use ($orderId, $shipped, $userId, $lockStatus, $precondition) { $shopping_order = ShoppingOrder::query()->lockForUpdate()->findOrFail($orderId); + if ($precondition !== null && ($error = $precondition($shopping_order)) !== null) { + throw ValidationException::withMessages(['shipped' => $error]); + } $shopping_order->shipped = $shipped; if ($lockStatus) { // Kundenentscheid 16.07.2026 (AP-33): Status aus der WaWi gesetzt ⇒ diff --git a/tests/Feature/IngredientDensityTest.php b/tests/Feature/IngredientDensityTest.php index 5609bbe..8823b2b 100644 --- a/tests/Feature/IngredientDensityTest.php +++ b/tests/Feature/IngredientDensityTest.php @@ -5,8 +5,10 @@ use App\Models\Location; use App\Models\MaterialQuality; use App\Models\StockEntry; use App\Models\Supplier; +use App\Repositories\StockEntryRepository; use App\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Validation\ValidationException; use Tests\TestCase; uses(TestCase::class, RefreshDatabase::class); @@ -172,3 +174,25 @@ test('F-15: kopie nutzt den dichte-snapshot, wenn die rohstoff-dichte inzwischen ->and((float) $copy->density_used)->toBe(0.80) ->and((float) $copy->ordered_quantity)->toBe(200.0); }); + +test('F-16: wareneingang prueft „offen“ innerhalb der sperre — zweite buchung wird abgelehnt', function () { + [$admin, $ingredient, $location, $supplier, $quality] = densSetup(null); + $this->actingAs($admin, 'user'); + + $this->post(route('admin.inventory.stock-entries.store'), [ + 'entry_type' => 'ingredient', 'ingredient_id' => (string) $ingredient->id, 'supplier_id' => (string) $supplier->id, + 'location_id' => (string) $location->id, 'quality_id' => (string) $quality->id, 'ordered_at' => '2026-08-01', + 'ordered_quantity' => '500', 'ordered_unit' => 'gram', 'price_per_kg' => '10,00', + ])->assertRedirect(); + $entry = StockEntry::query()->latest('id')->firstOrFail(); + + $repo = app(StockEntryRepository::class); + $repo->receive($entry, ['received_at' => '2026-08-05', 'received_quantity' => 500, 'batch_number' => 'F16-1']); + + // Zweiter Request mit veraltetem Modell (noch „pending“ im Speicher) muss scheitern. + expect(fn () => $repo->receive($entry, ['received_at' => '2026-08-06', 'received_quantity' => 999, 'batch_number' => 'F16-2'])) + ->toThrow(ValidationException::class); + + expect((float) $entry->fresh()->received_quantity)->toBe(500.0) + ->and($entry->fresh()->batch_number)->toBe('F16-1'); +}); diff --git a/tests/Feature/OrderFulfillmentTest.php b/tests/Feature/OrderFulfillmentTest.php index 52a9bb7..2a23dd3 100644 --- a/tests/Feature/OrderFulfillmentTest.php +++ b/tests/Feature/OrderFulfillmentTest.php @@ -12,10 +12,12 @@ use App\Models\ShoppingPayment; use App\Models\ShoppingUser; use App\Models\UserAccount; use App\Services\Invoice; +use App\Services\OrderStatusService; use App\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Storage; +use Illuminate\Validation\ValidationException; use Tests\TestCase; uses(TestCase::class, RefreshDatabase::class); @@ -455,3 +457,18 @@ test('F-10: ohne zahlungsfreigabe kein versandabschluss ueber das board, konfekt expect((int) $order->fresh()->shipped)->toBe(2) ->and(ProductStockMovement::query()->where('product_id', $product->id)->count())->toBe(1); }); + +test('F-16: statusservice prueft die vorbedingung innerhalb der sperre erneut', function () { + $order = ofMakeOrder([], ['shipped' => 0]); + + expect(fn () => app(OrderStatusService::class)->setShipped( + $order->id, 2, null, true, fn (ShoppingOrder $o) => __('abgelehnt') + ))->toThrow(ValidationException::class); + + expect((int) $order->fresh()->shipped)->toBe(0) + ->and($order->fresh()->shipped_status_locked)->toBeFalsy(); + + // Ohne Fehler aus der Vorbedingung läuft der Wechsel wie gehabt. + app(OrderStatusService::class)->setShipped($order->id, 1, null, true, fn (ShoppingOrder $o) => null); + expect((int) $order->fresh()->shipped)->toBe(1); +});