Warenwirtschaft: AP-09 bis AP-13 (Produktbestand, Set-Produkte, Ausschuss, Konzepte)

- AP-09 Produktbestand inkl. Bewegungshistorie (product_stock_movements, ProductStockService)
- AP-10 Rohstoffbestand-Ansicht je Lager (RawMaterialStockController)
- AP-11 Bestandsschwellen / Out-of-Stock-Handling fuer Produkte und Shop
- AP-12 Ausgang/Ausschuss (stock_disposals, StockDisposalController, InventoryService)
- Set-Produkte (product_set_items) inkl. Aufloesung
- Produktentwicklung & Hinweise-Verwaltung (Notices)
- AP-13 Entwicklungskonzept Shop-Bestandsabzug im Plan dokumentiert
- Feature-Tests fuer neue Module + aktualisierter Entwicklungsplan

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Kevin Adametz 2026-06-03 11:04:22 +00:00
parent 78679e0c55
commit 3ee2d756e9
63 changed files with 5968 additions and 901 deletions

View file

@ -9,6 +9,7 @@ use App\Models\Product;
use App\Models\ProductImage;
use App\Models\ProductIngredient;
use App\Repositories\ProductRepository;
use Illuminate\Database\Eloquent\Collection;
use Request;
use Validator;
@ -47,7 +48,7 @@ class ProductController extends Controller
$model->active = true;
} else {
$model = Product::findOrFail($id);
$model->load(['packagings.packagingMaterial']);
$model->load(['packagings.packagingMaterial', 'setItems']);
}
$country_for_prices = Country::where('own_eur', '=', true)->orWhere('currency', '=', true)->get();
@ -56,11 +57,27 @@ class ProductController extends Controller
'country_for_prices' => $country_for_prices,
'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),
];
return view('admin.product.edit', $data);
}
/**
* Auswählbare Set-Bestandteile: aktive Einzelprodukte (keine Sets, nicht das Produkt selbst).
*
* @return Collection<int, Product>
*/
protected function setProductCatalog(Product $model)
{
return Product::query()
->where('active', true)
->singleProducts()
->when($model->id, fn ($q) => $q->where('id', '!=', $model->id))
->orderBy('name')
->get(['id', 'name', 'number']);
}
public function store()
{
@ -87,15 +104,18 @@ class ProductController extends Controller
$model = new Product;
} else {
$model = Product::findOrFail($data['id']);
$model->load(['packagings.packagingMaterial']);
$model->load(['packagings.packagingMaterial', 'setItems']);
}
$country_for_prices = Country::where('own_eur', '=', true)->orWhere('currency', '=', true)->get();
$this->validateSetItems($validator, Request::all(), $model);
$data = [
'product' => $model,
'country_for_prices' => $country_for_prices,
'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),
];
if ($validator->fails()) {
@ -110,6 +130,43 @@ class ProductController extends Controller
}
}
/**
* Set-Validierung: Set braucht mindestens ein Einzelprodukt als Bestandteil
* (keine Sets, nicht das Produkt selbst).
*
* @param array<string, mixed> $data
*/
protected function validateSetItems($validator, array $data, Product $model): void
{
if (! isset($data['is_set'])) {
return;
}
$validator->after(function ($validator) use ($data, $model) {
$componentIds = collect($data['set_component_id'] ?? [])
->map(fn ($id) => (int) $id)
->filter(fn (int $id) => $id > 0 && $id !== (int) $model->id)
->unique()
->values();
if ($componentIds->isEmpty()) {
$validator->errors()->add('set_component_id', __('Ein Set benötigt mindestens ein Einzelprodukt als Bestandteil.'));
return;
}
$components = Product::query()->whereIn('id', $componentIds)->get(['id', 'is_set']);
if ($components->count() !== $componentIds->count()) {
$validator->errors()->add('set_component_id', __('Mindestens ein gewählter Bestandteil existiert nicht.'));
}
if ($components->where('is_set', true)->isNotEmpty()) {
$validator->errors()->add('set_component_id', __('Set-Bestandteile dürfen selbst keine Sets sein.'));
}
});
}
public function copy($id)
{
$model = Product::findOrFail($id);