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

@ -33,8 +33,39 @@ class ProductRepository extends BaseRepository
$data['whitelabel'] = isset($data['whitelabel']) ? 1 : 0;
$data['shipping_addon'] = isset($data['shipping_addon']) ? 1 : 0;
$data['max_buy'] = isset($data['max_buy']) ? 1 : 0;
$data['no_recipe_required'] = isset($data['no_recipe_required']) ? 1 : 0;
$data['is_set'] = isset($data['is_set']) ? 1 : 0;
$data['show_on'] = isset($data['show_on']) ? $data['show_on'] : null;
$data['main_product_quantity'] = isset($data['main_product_quantity']) && $data['main_product_quantity'] !== ''
? (int) $data['main_product_quantity']
: null;
// Ein Set ist selbst keine Variante eines Hauptprodukts.
$data['main_product_id'] = ! $data['is_set'] && isset($data['main_product_id']) && $data['main_product_id'] !== ''
? (int) $data['main_product_id']
: null;
// 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'])
: null;
$data['critical_product_stock'] = isset($data['critical_product_stock']) && $data['critical_product_stock'] !== ''
? max(0, (int) $data['critical_product_stock'])
: null;
// AP-03: „Nicht vorrätig"-Status. „Unbestimmt" hat Vorrang vor der Tagesangabe.
$data['out_of_stock_indefinite'] = isset($data['out_of_stock_indefinite']) ? 1 : 0;
if ($data['out_of_stock_indefinite']) {
$data['out_of_stock_until'] = null;
} elseif (isset($data['out_of_stock_active']) && isset($data['out_of_stock_days']) && $data['out_of_stock_days'] !== '') {
$days = max(0, (int) $data['out_of_stock_days']);
$data['out_of_stock_until'] = now()->addDays($days)->startOfDay();
} else {
$data['out_of_stock_until'] = null;
}
if (array_key_exists('shelf_life_type', $data)) {
if ($data['shelf_life_type'] === '' || $data['shelf_life_type'] === null) {
$data['shelf_life_type'] = null;
@ -60,14 +91,54 @@ class ProductRepository extends BaseRepository
$this->updateWLVariants(isset($data['whitelabel_variants']) ? $data['whitelabel_variants'] : []);
$this->updateWLImageAttributs(isset($data['image_wl_attributes']) ? $data['image_wl_attributes'] : [], isset($data['whitelabel_variants']) ? $data['whitelabel_variants'] : []);
$this->updateIngredients($data);
$this->updateManufacturerIngredients($data);
$this->updatePackagings($data);
if ($this->model->is_set) {
// Sets haben keine eigene Rezeptur/Verpackung und werden nicht produziert.
ProductIngredient::where('product_id', $this->model->id)->delete();
$this->model->packagings()->detach();
$this->updateSetItems($data);
} else {
$this->model->setItems()->detach();
$this->updateIngredients($data);
$this->updateManufacturerIngredients($data);
$this->updatePackagings($data);
}
$this->updateCountryPrices($data);
return $this->model;
}
public function updateSetItems(array $data = []): bool
{
if (! isset($data['set_component_id']) || ! is_array($data['set_component_id'])) {
$this->model->setItems()->detach();
return true;
}
$ids = $data['set_component_id'];
$quantities = $data['set_quantity'] ?? [];
$syncData = [];
foreach ($ids as $index => $componentId) {
$cid = (int) $componentId;
if ($cid <= 0 || $cid === (int) $this->model->id) {
continue;
}
$qty = (int) ($quantities[$index] ?? 1);
if ($qty < 1) {
$qty = 1;
}
$syncData[$cid] = [
'quantity' => $qty,
'pos' => (int) $index,
];
}
$this->model->setItems()->sync($syncData);
return true;
}
public function updatePackagings(array $data = []): bool
{
if (! isset($data['pp_packaging_item_id']) || ! is_array($data['pp_packaging_item_id'])) {
@ -400,6 +471,17 @@ class ProductRepository extends BaseRepository
}
$this->model->packagings()->sync($packSync);
$setSync = [];
foreach ($model->setItems()->get() as $component) {
$setSync[$component->id] = [
'quantity' => $component->pivot->quantity !== null ? (int) $component->pivot->quantity : 1,
'pos' => (int) ($component->pivot->pos ?? 0),
];
}
if ($setSync !== []) {
$this->model->setItems()->sync($setSync);
}
foreach ($model->country_prices as $cp) {
CountryPrice::create([
'country_id' => $cp->country_id,