Warenwirtschaft: AP-51 — Lagerplatz-Felder für Produkte & Verpackung
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 <noreply@anthropic.com>
This commit is contained in:
parent
eb47c8a82d
commit
30d4e28696
16 changed files with 363 additions and 5 deletions
|
|
@ -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<string, Collection<int, mixed>>
|
||||
*/
|
||||
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(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<StorageRoom, $this>
|
||||
*/
|
||||
public function storageRoom(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(StorageRoom::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<StorageShelf, $this>
|
||||
*/
|
||||
public function storageShelf(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(StorageShelf::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<StorageSlot, $this>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<StorageRoom, $this>
|
||||
*/
|
||||
public function storageRoom(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(StorageRoom::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<StorageShelf, $this>
|
||||
*/
|
||||
public function storageShelf(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(StorageShelf::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<StorageSlot, $this>
|
||||
*/
|
||||
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).
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* AP-51: Lagerplatz-Zuordnung (Raum/Regal/Fach) auch für Produkte und
|
||||
* Verpackungsartikel — gleiche Storage-Hierarchie wie bei Rohstoffen
|
||||
* (StorageRoom/Shelf/Slot). Referenzen werden beim Löschen eines
|
||||
* Lagerbausteins genullt (nullOnDelete).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('products', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -84,6 +84,37 @@
|
|||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group col-sm-4">
|
||||
<label class="form-label" for="storage_room_id">{{ __('Lagerort: Raum') }}</label>
|
||||
<select name="storage_room_id" id="storage_room_id" class="form-control">
|
||||
<option value="">{{ __('— keine Angabe —') }}</option>
|
||||
@foreach($storageRooms as $room)
|
||||
<option value="{{ $room->id }}" @selected((string) old('storage_room_id', $model->storage_room_id) === (string) $room->id)>{{ $room->label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group col-sm-4">
|
||||
<label class="form-label" for="storage_shelf_id">{{ __('Lagerort: Regal') }}</label>
|
||||
<select name="storage_shelf_id" id="storage_shelf_id" class="form-control">
|
||||
<option value="">{{ __('— keine Angabe —') }}</option>
|
||||
@foreach($storageShelves as $shelf)
|
||||
<option value="{{ $shelf->id }}" @selected((string) old('storage_shelf_id', $model->storage_shelf_id) === (string) $shelf->id)>{{ $shelf->label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group col-sm-4">
|
||||
<label class="form-label" for="storage_slot_id">{{ __('Lagerort: Fach / Buchstabe') }}</label>
|
||||
<select name="storage_slot_id" id="storage_slot_id" class="form-control">
|
||||
<option value="">{{ __('— keine Angabe —') }}</option>
|
||||
@foreach($storageSlots as $slot)
|
||||
<option value="{{ $slot->id }}" @selected((string) old('storage_slot_id', $model->storage_slot_id) === (string) $slot->id)>{{ $slot->label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<small class="text-muted">{{ __('Bausteine zentral pflegbar unter Einstellungen → Allgemein.') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="custom-control custom-checkbox">
|
||||
<input type="checkbox" name="active" value="1" class="custom-control-input"
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
<th>{{ __('Material') }}</th>
|
||||
<th>{{ __('Gewicht (g)') }}</th>
|
||||
<th>{{ __('Lieferant') }}</th>
|
||||
<th>{{ __('Lagerplatz') }}</th>
|
||||
<th>{{ __('URL') }}</th>
|
||||
<th>{{ __('Status') }}</th>
|
||||
<th></th>
|
||||
|
|
@ -40,6 +41,7 @@
|
|||
<td>{{ $value->packagingMaterial?->name ?? '—' }}</td>
|
||||
<td>{{ $value->weight_grams }}</td>
|
||||
<td>{{ $value->supplier?->name ?? '—' }}</td>
|
||||
<td>{{ $value->storageLabel() ?? '—' }}</td>
|
||||
<td>
|
||||
@if($value->url)
|
||||
<a href="{{ $value->url }}" target="_blank" title="{{ $value->url }}"><i class="fa fa-external-link-alt"></i></a>
|
||||
|
|
|
|||
|
|
@ -103,6 +103,9 @@
|
|||
@if (!empty($product->number))
|
||||
<div class="wawi-item-sub">{{ $product->number }}</div>
|
||||
@endif
|
||||
@if ($product->storageLabel())
|
||||
<div class="wawi-item-sub text-muted"><span class="fas fa-map-marker-alt mr-1"></span>{{ $product->storageLabel() }}</div>
|
||||
@endif
|
||||
</td>
|
||||
<td class="text-right {{ $stockClass }}">
|
||||
{{ \App\Services\Util::formatNumber($row['stock'], 0) }} <span class="text-muted">{{ __('Stück') }}</span>
|
||||
|
|
|
|||
|
|
@ -1140,6 +1140,38 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<p class="text-muted small mb-2">{{ __('Lagerplatz (Raum / Regal / Fach). Bausteine zentral pflegbar unter Einstellungen → Allgemein.') }}</p>
|
||||
<div class="form-row">
|
||||
<div class="form-group col-sm-4">
|
||||
<label class="form-label" for="storage_room_id">{{ __('Lagerort: Raum') }}</label>
|
||||
<select name="storage_room_id" id="storage_room_id" class="form-control">
|
||||
<option value="">{{ __('— keine Angabe —') }}</option>
|
||||
@foreach ($storageRooms as $room)
|
||||
<option value="{{ $room->id }}" @selected((string) old('storage_room_id', $product->storage_room_id) === (string) $room->id)>{{ $room->label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group col-sm-4">
|
||||
<label class="form-label" for="storage_shelf_id">{{ __('Lagerort: Regal') }}</label>
|
||||
<select name="storage_shelf_id" id="storage_shelf_id" class="form-control">
|
||||
<option value="">{{ __('— keine Angabe —') }}</option>
|
||||
@foreach ($storageShelves as $shelf)
|
||||
<option value="{{ $shelf->id }}" @selected((string) old('storage_shelf_id', $product->storage_shelf_id) === (string) $shelf->id)>{{ $shelf->label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group col-sm-4">
|
||||
<label class="form-label" for="storage_slot_id">{{ __('Lagerort: Fach / Buchstabe') }}</label>
|
||||
<select name="storage_slot_id" id="storage_slot_id" class="form-control">
|
||||
<option value="">{{ __('— keine Angabe —') }}</option>
|
||||
@foreach ($storageSlots as $slot)
|
||||
<option value="{{ $slot->id }}" @selected((string) old('storage_slot_id', $product->storage_slot_id) === (string) $slot->id)>{{ $slot->label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<p class="text-muted small mb-2">{{ __('Optional: dieses Einzelprodukt einem Hauptprodukt zuordnen (z. B. „50 × 15 ml"). Der Produktbestand führt nur Haupt-/Einzelprodukte.') }}</p>
|
||||
<div class="form-row">
|
||||
|
|
|
|||
103
tests/Feature/StorageAssignmentTest.php
Normal file
103
tests/Feature/StorageAssignmentTest.php
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
<?php
|
||||
|
||||
use App\Models\PackagingItem;
|
||||
use App\Models\PackagingMaterial;
|
||||
use App\Models\Product;
|
||||
use App\Models\StorageRoom;
|
||||
use App\Models\StorageShelf;
|
||||
use App\Models\StorageSlot;
|
||||
use App\Repositories\ProductRepository;
|
||||
use App\Services\DeletionGuard;
|
||||
use App\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
function storageTestUser(int $adminLevel = 9): User
|
||||
{
|
||||
$user = User::query()->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();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue