From 30d4e28696697c60da8439add6e8228739b45eee Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Fri, 28 Aug 2026 09:46:40 +0000 Subject: [PATCH] =?UTF-8?q?Warenwirtschaft:=20AP-51=20=E2=80=94=20Lagerpla?= =?UTF-8?q?tz-Felder=20f=C3=BCr=20Produkte=20&=20Verpackung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lagerplatz-Zuordnung (Raum/Regal/Fach) gab es bisher nur bei Rohstoffen. Jetzt dieselbe Storage-Hierarchie (StorageRoom/Shelf/Slot) auch für Produkte und Verpackungsartikel, inkl. Anzeige an den passenden Stellen. - Migration: storage_room_id/shelf/slot an products + packaging_items (nullable, FK constrained, nullOnDelete). - Models Product + PackagingItem: fillable + storageRoom/Shelf/Slot-Relationen + storageLabel() („Raum 1 | Regal 3 | A"). - Produktformular (Inventar-Abschnitt) + Verpackungsformular: Auswahl-Zeile Raum/Regal/Fach; Optionen kommen aktiv/sortiert aus den zentral pflegbaren Bausteinen (Einstellungen → Allgemein). - Speichern: ProductRepository normalisiert leere Auswahl -> null (FK); PackagingItem-Requests + Repository um die drei Felder erweitert. - Anzeige: Produktbestand (Sub-Zeile mit Marker), Produktverpackung-Übersicht (neue Spalte „Lagerplatz"). - DeletionGuard: Lagerbaustein ist nicht löschbar, solange Produkte/Verpackung darauf verweisen (analog Rohstoffe). Tests: 4 neue (Produkt speichern + leer->null, Verpackung speichern + Anzeige, Löschsperre, freier Baustein löschbar). Migration auf Dev-/Live-DB gelaufen. Suite 331 grün. Co-Authored-By: Claude Opus 4.8 --- .../Inventory/PackagingItemController.php | 22 +++- .../Inventory/ProductStockController.php | 2 +- app/Http/Controllers/ProductController.php | 9 ++ .../Inventory/StorePackagingItemRequest.php | 5 +- .../Inventory/UpdatePackagingItemRequest.php | 5 +- app/Models/PackagingItem.php | 43 ++++++++ app/Models/Product.php | 43 ++++++++ app/Repositories/PackagingItemRepository.php | 5 +- app/Repositories/ProductRepository.php | 9 ++ app/Services/DeletionGuard.php | 4 + ...torage_to_products_and_packaging_items.php | 50 +++++++++ .../inventory/packaging-items/form.blade.php | 31 ++++++ .../inventory/packaging-items/index.blade.php | 2 + .../inventory/product-stock/index.blade.php | 3 + resources/views/admin/product/form.blade.php | 32 ++++++ tests/Feature/StorageAssignmentTest.php | 103 ++++++++++++++++++ 16 files changed, 363 insertions(+), 5 deletions(-) create mode 100644 database/migrations/2026_08_28_120000_add_storage_to_products_and_packaging_items.php create mode 100644 tests/Feature/StorageAssignmentTest.php diff --git a/app/Http/Controllers/Admin/Inventory/PackagingItemController.php b/app/Http/Controllers/Admin/Inventory/PackagingItemController.php index 539a05d..bff240c 100644 --- a/app/Http/Controllers/Admin/Inventory/PackagingItemController.php +++ b/app/Http/Controllers/Admin/Inventory/PackagingItemController.php @@ -7,10 +7,14 @@ use App\Http\Requests\Inventory\StorePackagingItemRequest; use App\Http\Requests\Inventory\UpdatePackagingItemRequest; use App\Models\PackagingItem; use App\Models\PackagingMaterial; +use App\Models\StorageRoom; +use App\Models\StorageShelf; +use App\Models\StorageSlot; use App\Models\Supplier; use App\Repositories\PackagingItemRepository; use App\Services\DeletionGuard; use Illuminate\Http\Request; +use Illuminate\Support\Collection; class PackagingItemController extends Controller { @@ -24,7 +28,7 @@ class PackagingItemController extends Controller $isShipping = $category === 'shipping'; $query = PackagingItem::query() - ->with(['packagingMaterial', 'supplier']) + ->with(['packagingMaterial', 'supplier', 'storageRoom', 'storageShelf', 'storageSlot']) ->orderBy('name'); if ($isShipping) { @@ -52,9 +56,24 @@ class PackagingItemController extends Controller 'packagingMaterials' => PackagingMaterial::query()->orderBy('pos')->orderBy('name')->get(), 'suppliers' => Supplier::query()->orderBy('name')->get(), 'category' => $category, + ...$this->storageOptions(), ]); } + /** + * AP-51: Lagerplatz-Bausteine (Raum/Regal/Fach) für das Formular. + * + * @return array> + */ + private function storageOptions(): array + { + return [ + 'storageRooms' => StorageRoom::query()->active()->orderBy('pos')->orderBy('label')->get(), + 'storageShelves' => StorageShelf::query()->active()->orderBy('pos')->orderBy('label')->get(), + 'storageSlots' => StorageSlot::query()->active()->orderBy('pos')->orderBy('label')->get(), + ]; + } + public function store(StorePackagingItemRequest $request) { $validated = $request->validated(); @@ -77,6 +96,7 @@ class PackagingItemController extends Controller 'packagingMaterials' => PackagingMaterial::query()->orderBy('pos')->orderBy('name')->get(), 'suppliers' => Supplier::query()->orderBy('name')->get(), 'category' => $category, + ...$this->storageOptions(), ]); } diff --git a/app/Http/Controllers/Admin/Inventory/ProductStockController.php b/app/Http/Controllers/Admin/Inventory/ProductStockController.php index 41cc4d8..70b001f 100644 --- a/app/Http/Controllers/Admin/Inventory/ProductStockController.php +++ b/app/Http/Controllers/Admin/Inventory/ProductStockController.php @@ -25,7 +25,7 @@ class ProductStockController extends Controller ->where('is_set', false) ->where('show_in_product_stock', true) ->whereNull('main_product_id') - ->with('images') + ->with(['images', 'storageRoom', 'storageShelf', 'storageSlot']) ->orderBy('pos') ->orderBy('name') ->get(); diff --git a/app/Http/Controllers/ProductController.php b/app/Http/Controllers/ProductController.php index f9fa8b4..bd0a444 100755 --- a/app/Http/Controllers/ProductController.php +++ b/app/Http/Controllers/ProductController.php @@ -8,6 +8,9 @@ use App\Models\PackagingItem; use App\Models\Product; use App\Models\ProductImage; use App\Models\ProductIngredient; +use App\Models\StorageRoom; +use App\Models\StorageShelf; +use App\Models\StorageSlot; use App\Repositories\ProductRepository; use Illuminate\Database\Eloquent\Collection; use Request; @@ -58,6 +61,9 @@ class ProductController extends Controller 'ingredient_catalog' => Ingredient::query()->where('active', true)->with('materialQuality')->orderBy('name')->get(['id', 'name', 'inci', 'effect', 'default_factor', 'material_quality_id']), 'packaging_catalog' => PackagingItem::query()->where('active', true)->with('packagingMaterial')->orderBy('name')->get(), 'set_product_catalog' => $this->setProductCatalog($model), + 'storageRooms' => StorageRoom::query()->active()->orderBy('pos')->orderBy('label')->get(), + 'storageShelves' => StorageShelf::query()->active()->orderBy('pos')->orderBy('label')->get(), + 'storageSlots' => StorageSlot::query()->active()->orderBy('pos')->orderBy('label')->get(), ]; return view('admin.product.edit', $data); @@ -127,6 +133,9 @@ class ProductController extends Controller 'ingredient_catalog' => Ingredient::query()->where('active', true)->with('materialQuality')->orderBy('name')->get(['id', 'name', 'inci', 'effect', 'default_factor', 'material_quality_id']), 'packaging_catalog' => PackagingItem::query()->where('active', true)->with('packagingMaterial')->orderBy('name')->get(), 'set_product_catalog' => $this->setProductCatalog($model), + 'storageRooms' => StorageRoom::query()->active()->orderBy('pos')->orderBy('label')->get(), + 'storageShelves' => StorageShelf::query()->active()->orderBy('pos')->orderBy('label')->get(), + 'storageSlots' => StorageSlot::query()->active()->orderBy('pos')->orderBy('label')->get(), ]; if ($validator->fails()) { diff --git a/app/Http/Requests/Inventory/StorePackagingItemRequest.php b/app/Http/Requests/Inventory/StorePackagingItemRequest.php index fa0b449..ed68edc 100644 --- a/app/Http/Requests/Inventory/StorePackagingItemRequest.php +++ b/app/Http/Requests/Inventory/StorePackagingItemRequest.php @@ -26,6 +26,9 @@ class StorePackagingItemRequest extends FormRequest 'min_stock_alert' => ['nullable', 'integer', 'min:0'], 'url' => ['nullable', 'string', 'url:http,https', 'max:2048'], 'product_id' => ['nullable', 'integer', 'exists:products,id'], + 'storage_room_id' => ['nullable', 'integer', 'exists:storage_rooms,id'], + 'storage_shelf_id' => ['nullable', 'integer', 'exists:storage_shelves,id'], + 'storage_slot_id' => ['nullable', 'integer', 'exists:storage_slots,id'], 'active' => ['sometimes', 'boolean'], ]; } @@ -38,7 +41,7 @@ class StorePackagingItemRequest extends FormRequest 'weight_grams' => $this->filled('weight_grams') ? reFormatWawiNumber($this->input('weight_grams')) : null, ]); - foreach (['supplier_id', 'product_id', 'min_stock_alert'] as $key) { + foreach (['supplier_id', 'product_id', 'min_stock_alert', 'storage_room_id', 'storage_shelf_id', 'storage_slot_id'] as $key) { if ($this->input($key) === '' || $this->input($key) === null) { $this->merge([$key => null]); } diff --git a/app/Http/Requests/Inventory/UpdatePackagingItemRequest.php b/app/Http/Requests/Inventory/UpdatePackagingItemRequest.php index 58005a3..5b032aa 100644 --- a/app/Http/Requests/Inventory/UpdatePackagingItemRequest.php +++ b/app/Http/Requests/Inventory/UpdatePackagingItemRequest.php @@ -26,6 +26,9 @@ class UpdatePackagingItemRequest extends FormRequest 'min_stock_alert' => ['nullable', 'integer', 'min:0'], 'url' => ['nullable', 'string', 'url:http,https', 'max:2048'], 'product_id' => ['nullable', 'integer', 'exists:products,id'], + 'storage_room_id' => ['nullable', 'integer', 'exists:storage_rooms,id'], + 'storage_shelf_id' => ['nullable', 'integer', 'exists:storage_shelves,id'], + 'storage_slot_id' => ['nullable', 'integer', 'exists:storage_slots,id'], 'active' => ['sometimes', 'boolean'], ]; } @@ -38,7 +41,7 @@ class UpdatePackagingItemRequest extends FormRequest 'weight_grams' => $this->filled('weight_grams') ? reFormatWawiNumber($this->input('weight_grams')) : null, ]); - foreach (['supplier_id', 'product_id', 'min_stock_alert'] as $key) { + foreach (['supplier_id', 'product_id', 'min_stock_alert', 'storage_room_id', 'storage_shelf_id', 'storage_slot_id'] as $key) { if ($this->input($key) === '' || $this->input($key) === null) { $this->merge([$key => null]); } diff --git a/app/Models/PackagingItem.php b/app/Models/PackagingItem.php index 90e1e9e..6d6f419 100644 --- a/app/Models/PackagingItem.php +++ b/app/Models/PackagingItem.php @@ -23,6 +23,9 @@ class PackagingItem extends Model 'min_stock_alert', 'url', 'product_id', + 'storage_room_id', + 'storage_shelf_id', + 'storage_slot_id', 'active', ]; @@ -72,4 +75,44 @@ class PackagingItem extends Model ->withPivot('quantity', 'pos') ->withTimestamps(); } + + /** + * AP-51: Lagerplatz (Raum/Regal/Fach) — gleiche Hierarchie wie beim Rohstoff. + * + * @return BelongsTo + */ + public function storageRoom(): BelongsTo + { + return $this->belongsTo(StorageRoom::class); + } + + /** + * @return BelongsTo + */ + public function storageShelf(): BelongsTo + { + return $this->belongsTo(StorageShelf::class); + } + + /** + * @return BelongsTo + */ + public function storageSlot(): BelongsTo + { + return $this->belongsTo(StorageSlot::class); + } + + /** + * Lagerplatz-Anzeige im Schema „Raum 1 | Regal 3 | A" (nur gepflegte Teile). + */ + public function storageLabel(): ?string + { + $parts = array_filter([ + $this->storageRoom?->label, + $this->storageShelf?->label, + $this->storageSlot?->label, + ]); + + return $parts === [] ? null : implode(' | ', $parts); + } } diff --git a/app/Models/Product.php b/app/Models/Product.php index 047bf0d..8539976 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -237,6 +237,9 @@ class Product extends Model 'contents_total', 'fill_weight', 'production_relevant', + 'storage_room_id', + 'storage_shelf_id', + 'storage_slot_id', 'unit', 'number', 'wp_number', @@ -404,6 +407,46 @@ class Product extends Model return $this->hasMany(Production::class); } + /** + * AP-51: Lagerplatz (Raum/Regal/Fach) — gleiche Hierarchie wie beim Rohstoff. + * + * @return BelongsTo + */ + public function storageRoom(): BelongsTo + { + return $this->belongsTo(StorageRoom::class); + } + + /** + * @return BelongsTo + */ + public function storageShelf(): BelongsTo + { + return $this->belongsTo(StorageShelf::class); + } + + /** + * @return BelongsTo + */ + public function storageSlot(): BelongsTo + { + return $this->belongsTo(StorageSlot::class); + } + + /** + * Lagerplatz-Anzeige im Schema „Raum 1 | Regal 3 | A" (nur gepflegte Teile). + */ + public function storageLabel(): ?string + { + $parts = array_filter([ + $this->storageRoom?->label, + $this->storageShelf?->label, + $this->storageSlot?->label, + ]); + + return $parts === [] ? null : implode(' | ', $parts); + } + /** * Produktbestands-Bewegungen (Eingang/Ausgang). * diff --git a/app/Repositories/PackagingItemRepository.php b/app/Repositories/PackagingItemRepository.php index 981f6e4..5affbc5 100644 --- a/app/Repositories/PackagingItemRepository.php +++ b/app/Repositories/PackagingItemRepository.php @@ -23,7 +23,7 @@ class PackagingItemRepository 'error' => $e->getMessage(), 'sql' => $lastQ['query'] ?? 'unknown', 'bindings' => $lastQ['bindings'] ?? [], - 'binding_types' => array_map(fn($v) => gettype($v) . ':' . json_encode($v), $lastQ['bindings'] ?? []), + 'binding_types' => array_map(fn ($v) => gettype($v).':'.json_encode($v), $lastQ['bindings'] ?? []), 'attributes_passed' => $attrs, ]); @@ -60,6 +60,9 @@ class PackagingItemRepository 'min_stock_alert', 'url', 'product_id', + 'storage_room_id', + 'storage_shelf_id', + 'storage_slot_id', 'active', ])->all(); diff --git a/app/Repositories/ProductRepository.php b/app/Repositories/ProductRepository.php index 4ef994c..d0dfad9 100644 --- a/app/Repositories/ProductRepository.php +++ b/app/Repositories/ProductRepository.php @@ -57,6 +57,15 @@ class ProductRepository extends BaseRepository ? trim((string) $data['batch_prefix']) : null; + // AP-51: Lagerplatz (Raum/Regal/Fach) — leere Auswahl => null (FK). + foreach (['storage_room_id', 'storage_shelf_id', 'storage_slot_id'] as $storageKey) { + if (array_key_exists($storageKey, $data)) { + $data[$storageKey] = ($data[$storageKey] === '' || $data[$storageKey] === null) + ? null + : (int) $data[$storageKey]; + } + } + // AP-11: Produktbestand-Schwellwerte (leer => null). $data['min_product_stock'] = isset($data['min_product_stock']) && $data['min_product_stock'] !== '' ? max(0, (int) $data['min_product_stock']) diff --git a/app/Services/DeletionGuard.php b/app/Services/DeletionGuard.php index 8ea2f02..253a163 100644 --- a/app/Services/DeletionGuard.php +++ b/app/Services/DeletionGuard.php @@ -9,6 +9,7 @@ use App\Models\Location; use App\Models\MaterialQuality; use App\Models\PackagingItem; use App\Models\PackagingMaterial; +use App\Models\Product; use App\Models\ProductIngredient; use App\Models\Production; use App\Models\ProductionIngredient; @@ -136,6 +137,9 @@ class DeletionGuard $blockers = []; $this->push($blockers, Ingredient::query()->where($column, $segment->id)->count(), ':n Rohstoff(e)'); + // AP-51: Lagerplatz jetzt auch an Produkten und Verpackungsartikeln. + $this->push($blockers, Product::query()->where($column, $segment->id)->count(), ':n Produkt(e)'); + $this->push($blockers, PackagingItem::query()->where($column, $segment->id)->count(), ':n Verpackungsartikel'); return $blockers; } diff --git a/database/migrations/2026_08_28_120000_add_storage_to_products_and_packaging_items.php b/database/migrations/2026_08_28_120000_add_storage_to_products_and_packaging_items.php new file mode 100644 index 0000000..196055d --- /dev/null +++ b/database/migrations/2026_08_28_120000_add_storage_to_products_and_packaging_items.php @@ -0,0 +1,50 @@ +foreignId('storage_room_id')->nullable()->after('weight') + ->constrained('storage_rooms')->nullOnDelete(); + $table->foreignId('storage_shelf_id')->nullable()->after('storage_room_id') + ->constrained('storage_shelves')->nullOnDelete(); + $table->foreignId('storage_slot_id')->nullable()->after('storage_shelf_id') + ->constrained('storage_slots')->nullOnDelete(); + }); + + Schema::table('packaging_items', function (Blueprint $table) { + $table->foreignId('storage_room_id')->nullable()->after('product_id') + ->constrained('storage_rooms')->nullOnDelete(); + $table->foreignId('storage_shelf_id')->nullable()->after('storage_room_id') + ->constrained('storage_shelves')->nullOnDelete(); + $table->foreignId('storage_slot_id')->nullable()->after('storage_shelf_id') + ->constrained('storage_slots')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('products', function (Blueprint $table) { + $table->dropConstrainedForeignId('storage_room_id'); + $table->dropConstrainedForeignId('storage_shelf_id'); + $table->dropConstrainedForeignId('storage_slot_id'); + }); + + Schema::table('packaging_items', function (Blueprint $table) { + $table->dropConstrainedForeignId('storage_room_id'); + $table->dropConstrainedForeignId('storage_shelf_id'); + $table->dropConstrainedForeignId('storage_slot_id'); + }); + } +}; diff --git a/resources/views/admin/inventory/packaging-items/form.blade.php b/resources/views/admin/inventory/packaging-items/form.blade.php index 6169aa1..462a03d 100644 --- a/resources/views/admin/inventory/packaging-items/form.blade.php +++ b/resources/views/admin/inventory/packaging-items/form.blade.php @@ -84,6 +84,37 @@ @enderror +
+
+ + +
+
+ + +
+
+ + + {{ __('Bausteine zentral pflegbar unter Einstellungen → Allgemein.') }} +
+
+
+
+

{{ __('Lagerplatz (Raum / Regal / Fach). Bausteine zentral pflegbar unter Einstellungen → Allgemein.') }}

+
+
+ + +
+
+ + +
+
+ + +
+
+

{{ __('Optional: dieses Einzelprodukt einem Hauptprodukt zuordnen (z. B. „50 × 15 ml"). Der Produktbestand führt nur Haupt-/Einzelprodukte.') }}

diff --git a/tests/Feature/StorageAssignmentTest.php b/tests/Feature/StorageAssignmentTest.php new file mode 100644 index 0000000..8f702c3 --- /dev/null +++ b/tests/Feature/StorageAssignmentTest.php @@ -0,0 +1,103 @@ +create(['email' => uniqid('storage_', true).'@test.example', 'password' => bcrypt('password')]); + $user->forceFill(['admin' => $adminLevel, 'confirmed' => true, 'active' => true, 'wizard' => 100, 'blocked' => false])->save(); + + return $user->fresh(); +} + +test('AP-51: produkt speichert lagerplatz und setzt leere auswahl auf null', function () { + $room = StorageRoom::factory()->create(['label' => 'Raum 1']); + $shelf = StorageShelf::factory()->create(['label' => 'Regal 3']); + $slot = StorageSlot::factory()->create(['label' => 'A']); + + $product = Product::query()->create(['name' => 'AP51 Produkt', 'title' => 'AP51 Produkt', 'active' => true, 'is_set' => false]); + + (new ProductRepository($product))->update([ + 'id' => (string) $product->id, + 'name' => $product->name, + 'title' => $product->title, + 'active' => '1', + 'storage_room_id' => (string) $room->id, + 'storage_shelf_id' => (string) $shelf->id, + 'storage_slot_id' => (string) $slot->id, + ]); + + $product->refresh(); + expect($product->storage_room_id)->toBe($room->id) + ->and($product->storage_shelf_id)->toBe($shelf->id) + ->and($product->storage_slot_id)->toBe($slot->id) + ->and($product->storageLabel())->toBe('Raum 1 | Regal 3 | A'); + + // Leere Auswahl (Select „— keine Angabe —" sendet "") => null, kein FK-Fehler. + (new ProductRepository($product))->update([ + 'id' => (string) $product->id, + 'name' => $product->name, + 'title' => $product->title, + 'active' => '1', + 'storage_room_id' => '', + 'storage_shelf_id' => '', + 'storage_slot_id' => '', + ]); + + $product->refresh(); + expect($product->storage_room_id)->toBeNull() + ->and($product->storageLabel())->toBeNull(); +}); + +test('AP-51: verpackungsartikel speichert lagerplatz und zeigt ihn an', function () { + $material = PackagingMaterial::factory()->create(); + $room = StorageRoom::factory()->create(['label' => 'Raum 2']); + $slot = StorageSlot::factory()->create(['label' => 'B']); + $this->actingAs(storageTestUser(), 'user'); + + $this->post(route('admin.inventory.packaging-items.store'), [ + 'packaging_material_id' => (string) $material->id, + 'name' => 'AP51 Karton', + 'category' => 'packaging', + 'storage_room_id' => (string) $room->id, + 'storage_slot_id' => (string) $slot->id, + 'active' => '1', + ])->assertRedirect(route('admin.inventory.packaging-items.index', ['category' => 'packaging'])); + + $item = PackagingItem::query()->where('name', 'AP51 Karton')->firstOrFail(); + expect($item->storage_room_id)->toBe($room->id) + ->and($item->storage_shelf_id)->toBeNull() + ->and($item->storageLabel())->toBe('Raum 2 | B'); + + // Anzeige in der Übersicht + $this->get(route('admin.inventory.packaging-items.index', ['category' => 'packaging'])) + ->assertOk() + ->assertSee('Raum 2 | B'); +}); + +test('AP-51: lagerbaustein mit produkt-/verpackungs-referenz ist nicht löschbar', function () { + $room = StorageRoom::factory()->create(['label' => 'Raum belegt']); + Product::query()->create(['name' => 'AP51 Ref', 'title' => 'AP51 Ref', 'active' => true, 'is_set' => false, 'storage_room_id' => $room->id]); + + $blockers = app(DeletionGuard::class)->blockersFor($room); + expect($blockers)->not->toBeEmpty() + ->and(implode(' ', $blockers))->toContain('Produkt'); +}); + +test('AP-51: freier lagerbaustein bleibt löschbar', function () { + $room = StorageRoom::factory()->create(['label' => 'Raum frei']); + + expect(app(DeletionGuard::class)->blockersFor($room))->toBeEmpty(); +});