Warenwirtschaft: AP-21 INCI-Erweiterungen

- Lagerort-Stammdaten Raum/Regal/Fach (storage_rooms/shelves/slots) mit CRUD
  auf Einstellungen -> Allgemein (SettingCrudController-Kinder, ein gemeinsamer
  StoreStorageSegmentRequest, generische Partials setting-list-card/-segment-form)
- Ingredient: alt_name (Handelsname), Lagerort-FKs + storageLabel(),
  scopeUsedInActiveRecipes()
- INCI-Formular: Lieferanten-Repeater mit Shop-URL je Lieferant (leer = Mail),
  Alternativname, drei Lagerort-Dropdowns
- INCI-Liste: Filter "nur aktive (in Rezeptur aktiver Produkte)" + Spalte
  Alternativer Name
- Rohstoffbestand-Detail: Bestellweg pro INCI-Lieferant (Pivot-URL statt
  order_method-Fallback), Kopf zeigt Alternativname + Lagerort
- Hinweise-Doku fortgeschrieben inkl. Chargen-Format-Entscheid ttmmjj (AP-24)

Tests: StorageSettingsTest, IngredientActiveRecipeFilterTest,
IngredientOrderFieldsTest (Repeater-Format), RawMaterialStockTest erweitert
(volle Suite 170 gruen, 613 Assertions)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-06 11:36:28 +00:00
parent 8c90d95c69
commit d05df498d3
34 changed files with 1091 additions and 45 deletions

View file

@ -5,6 +5,9 @@ namespace App\Http\Controllers\Admin\Inventory;
use App\Http\Controllers\Controller;
use App\Models\DeliveryTime;
use App\Models\DisposalReason;
use App\Models\StorageRoom;
use App\Models\StorageShelf;
use App\Models\StorageSlot;
use App\Models\TaxRate;
use Illuminate\Contracts\View\View;
@ -16,6 +19,9 @@ class GeneralSettingController extends Controller
'taxRates' => TaxRate::query()->orderBy('pos')->orderBy('percent')->get(),
'deliveryTimes' => DeliveryTime::query()->orderBy('pos')->orderBy('label')->get(),
'disposalReasons' => DisposalReason::query()->orderBy('pos')->orderBy('label')->get(),
'storageRooms' => StorageRoom::query()->orderBy('pos')->orderBy('label')->get(),
'storageShelves' => StorageShelf::query()->orderBy('pos')->orderBy('label')->get(),
'storageSlots' => StorageSlot::query()->orderBy('pos')->orderBy('label')->get(),
]);
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\Admin\Inventory;
use App\Http\Requests\Inventory\StoreStorageSegmentRequest;
use App\Models\StorageRoom;
class StorageRoomController extends SettingCrudController
{
protected function modelClass(): string
{
return StorageRoom::class;
}
protected function viewPrefix(): string
{
return 'admin.inventory.storage-rooms';
}
protected function redirectRoute(): string
{
return 'admin.inventory.general';
}
protected function storeRequestClass(): string
{
return StoreStorageSegmentRequest::class;
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\Admin\Inventory;
use App\Http\Requests\Inventory\StoreStorageSegmentRequest;
use App\Models\StorageShelf;
class StorageShelfController extends SettingCrudController
{
protected function modelClass(): string
{
return StorageShelf::class;
}
protected function viewPrefix(): string
{
return 'admin.inventory.storage-shelves';
}
protected function redirectRoute(): string
{
return 'admin.inventory.general';
}
protected function storeRequestClass(): string
{
return StoreStorageSegmentRequest::class;
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\Admin\Inventory;
use App\Http\Requests\Inventory\StoreStorageSegmentRequest;
use App\Models\StorageSlot;
class StorageSlotController extends SettingCrudController
{
protected function modelClass(): string
{
return StorageSlot::class;
}
protected function viewPrefix(): string
{
return 'admin.inventory.storage-slots';
}
protected function redirectRoute(): string
{
return 'admin.inventory.general';
}
protected function storeRequestClass(): string
{
return StoreStorageSegmentRequest::class;
}
}

View file

@ -6,8 +6,12 @@ use App\Http\Requests\StoreIngredientRequest;
use App\Models\DeliveryTime;
use App\Models\Ingredient;
use App\Models\ProductIngredient;
use App\Models\StorageRoom;
use App\Models\StorageShelf;
use App\Models\StorageSlot;
use App\Models\Supplier;
use App\Models\TaxRate;
use Illuminate\Http\Request;
class IngredientController extends Controller
{
@ -16,11 +20,15 @@ class IngredientController extends Controller
$this->middleware('copyreader');
}
public function index()
public function index(Request $request)
{
$onlyActive = $request->boolean('only_active');
$data = [
'values' => Ingredient::all(),
'values' => Ingredient::query()
->when($onlyActive, fn ($q) => $q->usedInActiveRecipes())
->get(),
'onlyActive' => $onlyActive,
];
return view('admin.ingredient.index', $data);
@ -39,6 +47,9 @@ class IngredientController extends Controller
'taxRates' => TaxRate::query()->active()->orderBy('pos')->orderBy('name')->get(),
'deliveryTimes' => DeliveryTime::query()->active()->orderBy('pos')->orderBy('label')->get(),
'suppliers' => Supplier::query()->where('active', true)->orderBy('name')->get(),
'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.ingredient.edit', $data);
@ -49,8 +60,8 @@ class IngredientController extends Controller
$data = $request->validated();
$data['default_factor'] = $data['default_factor'] ?? 1.10;
$supplierIds = $request->input('supplier_ids', []);
unset($data['supplier_ids']);
$supplierRows = $data['suppliers'] ?? [];
unset($data['suppliers']);
if ($request->input('id') === 'new') {
$model = Ingredient::create($data);
@ -59,7 +70,16 @@ class IngredientController extends Controller
$model->fill($data)->save();
}
$model->suppliers()->sync($supplierIds);
// Repeater-Zeilen → Pivot-Sync; URL leer = Bestellung per Mail. Doppelte Lieferanten: letzte Zeile gewinnt.
$sync = [];
foreach ($supplierRows as $row) {
$supplierId = (int) ($row['id'] ?? 0);
if ($supplierId > 0) {
$url = trim((string) ($row['url'] ?? ''));
$sync[$supplierId] = ['url' => $url === '' ? null : $url];
}
}
$model->suppliers()->sync($sync);
\Session()->flash('alert-save', '1');

View file

@ -0,0 +1,36 @@
<?php
namespace App\Http\Requests\Inventory;
use Illuminate\Foundation\Http\FormRequest;
/**
* Gemeinsamer Request der Lagerort-Bausteine Raum/Regal/Fach (AP-21).
*/
class StoreStorageSegmentRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, array<int, string>>
*/
public function rules(): array
{
return [
'label' => ['required', 'string', 'max:100'],
'active' => ['sometimes', 'boolean'],
'pos' => ['nullable', 'integer', 'min:0', 'max:255'],
];
}
protected function prepareForValidation(): void
{
$this->merge([
'active' => $this->boolean('active'),
'pos' => $this->input('pos', '') === '' ? 0 : $this->input('pos'),
]);
}
}

View file

@ -18,6 +18,7 @@ class StoreIngredientRequest extends FormRequest
{
return [
'name' => ['required', 'string', 'max:255'],
'alt_name' => ['nullable', 'string', 'max:255'],
'inci' => ['nullable', 'string'],
'effect' => ['nullable', 'string'],
'pos' => ['nullable', 'integer', 'min:0', 'max:255'],
@ -27,8 +28,12 @@ class StoreIngredientRequest extends FormRequest
'tax_rate_id' => ['nullable', 'integer', 'exists:tax_rates,id'],
'delivery_time' => ['nullable', 'string', 'max:255'],
'delivery_time_days' => ['nullable', 'integer', 'min:0', 'max:65535'],
'supplier_ids' => ['nullable', 'array'],
'supplier_ids.*' => ['integer', 'exists:suppliers,id'],
'suppliers' => ['nullable', 'array'],
'suppliers.*.id' => ['nullable', 'integer', 'exists:suppliers,id'],
'suppliers.*.url' => ['nullable', 'string', 'max:2048'],
'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'],
];
}
@ -42,6 +47,9 @@ class StoreIngredientRequest extends FormRequest
'material_quality_id' => $this->nullIfEmpty($this->input('material_quality_id')),
'tax_rate_id' => $this->nullIfEmpty($this->input('tax_rate_id')),
'delivery_time_days' => $this->nullIfEmpty($this->input('delivery_time_days')),
'storage_room_id' => $this->nullIfEmpty($this->input('storage_room_id')),
'storage_shelf_id' => $this->nullIfEmpty($this->input('storage_shelf_id')),
'storage_slot_id' => $this->nullIfEmpty($this->input('storage_slot_id')),
]);
}

View file

@ -7,6 +7,7 @@
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -63,6 +64,7 @@ class Ingredient extends Model
protected $fillable = [
'name',
'trans_name',
'alt_name',
'inci',
'trans_inci',
'effect',
@ -75,6 +77,9 @@ class Ingredient extends Model
'tax_rate_id',
'delivery_time',
'delivery_time_days',
'storage_room_id',
'storage_shelf_id',
'storage_slot_id',
];
/**
@ -115,4 +120,57 @@ class Ingredient extends Model
{
return $this->hasMany(ProductIngredient::class, 'ingredient_id');
}
/**
* @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);
}
/**
* Lagerort-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);
}
/**
* Nur INCI, die in der Hersteller-Rezeptur mindestens eines aktiven Produkts vorkommen
* (fachlich „aktiv" laut Kunde — nicht zu verwechseln mit `ingredients.active`).
*
* @param Builder<Ingredient> $query
* @return Builder<Ingredient>
*/
public function scopeUsedInActiveRecipes($query)
{
return $query->whereHas('product_ingredients', function ($q) {
$q->where('recipe_type', 'manufacturer')
->whereHas('product', fn ($p) => $p->where('active', true));
});
}
}

View file

@ -0,0 +1,14 @@
<?php
namespace App\Models;
use Database\Factories\StorageRoomFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class StorageRoom extends StorageSegment
{
/** @use HasFactory<StorageRoomFactory> */
use HasFactory;
protected $table = 'storage_rooms';
}

View file

@ -0,0 +1,38 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
/**
* Gemeinsame Basis der Lagerort-Bausteine Raum/Regal/Fach (AP-21).
*/
abstract class StorageSegment extends Model
{
protected $fillable = [
'label',
'active',
'pos',
];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'active' => 'boolean',
'pos' => 'integer',
];
}
/**
* @param Builder<static> $query
* @return Builder<static>
*/
public function scopeActive(Builder $query): Builder
{
return $query->where('active', true);
}
}

View file

@ -0,0 +1,14 @@
<?php
namespace App\Models;
use Database\Factories\StorageShelfFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class StorageShelf extends StorageSegment
{
/** @use HasFactory<StorageShelfFactory> */
use HasFactory;
protected $table = 'storage_shelves';
}

View file

@ -0,0 +1,14 @@
<?php
namespace App\Models;
use Database\Factories\StorageSlotFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class StorageSlot extends StorageSegment
{
/** @use HasFactory<StorageSlotFactory> */
use HasFactory;
protected $table = 'storage_slots';
}