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:
parent
8c90d95c69
commit
d05df498d3
34 changed files with 1091 additions and 45 deletions
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
36
app/Http/Requests/Inventory/StoreStorageSegmentRequest.php
Normal file
36
app/Http/Requests/Inventory/StoreStorageSegmentRequest.php
Normal 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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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')),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
14
app/Models/StorageRoom.php
Normal file
14
app/Models/StorageRoom.php
Normal 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';
|
||||
}
|
||||
38
app/Models/StorageSegment.php
Normal file
38
app/Models/StorageSegment.php
Normal 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);
|
||||
}
|
||||
}
|
||||
14
app/Models/StorageShelf.php
Normal file
14
app/Models/StorageShelf.php
Normal 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';
|
||||
}
|
||||
14
app/Models/StorageSlot.php
Normal file
14
app/Models/StorageSlot.php
Normal 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';
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue