model = $model; } /** * refresh. */ public function update($data) { $data['active'] = isset($data['active']) ? 1 : 0; $data['single_commission'] = isset($data['single_commission']) ? 1 : 0; $data['amount_commission'] = isset($data['amount_commission']) ? 1 : 0; $data['exclude_stats_sales'] = isset($data['exclude_stats_sales']) ? 1 : 0; $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-22: Sichtbarkeit im Produktbestand (Checkbox, Default sichtbar). $data['show_in_product_stock'] = isset($data['show_in_product_stock']) ? 1 : 0; // AP-24: Chargen-Nr.-Präfix (leer => null). $data['batch_prefix'] = isset($data['batch_prefix']) && trim((string) $data['batch_prefix']) !== '' ? 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']) : null; $data['critical_product_stock'] = isset($data['critical_product_stock']) && $data['critical_product_stock'] !== '' ? max(0, (int) $data['critical_product_stock']) : null; // AP-03/AP-25: „Nicht vorrätig"-Status. „Unbestimmt" hat Vorrang vor der Datumsangabe. // Eingabe als festes Datum („Wieder lieferbar ab", dd.mm.yyyy); Resttage werden daraus berechnet. $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_date']) && $data['out_of_stock_date'] !== '') { try { $data['out_of_stock_until'] = Carbon::createFromFormat('d.m.Y', trim($data['out_of_stock_date']))->startOfDay(); } catch (\Throwable) { $data['out_of_stock_until'] = null; } } 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; $data['shelf_life_months'] = null; } elseif ($data['shelf_life_type'] === 'pao') { $data['shelf_life_months'] = null; } elseif ($data['shelf_life_type'] === 'fixed' && array_key_exists('shelf_life_months', $data) && $data['shelf_life_months'] !== '' && $data['shelf_life_months'] !== null) { $data['shelf_life_months'] = (int) $data['shelf_life_months']; } } if ($data['id'] === 'new') { $this->model = Product::create($data); } else { $this->model = $this->getById($data['id']); $this->model->fill($data); $this->model->save(); } $this->updateCategories(isset($data['categories']) ? $data['categories'] : []); $this->updateAttributes(isset($data['attributes']) ? $data['attributes'] : []); $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'] : []); if ($this->model->is_set) { // Sets haben keine eigene Rezeptur/Verpackung und werden nicht produziert. ProductIngredient::where('product_id', $this->model->id)->delete(); // Audit N-11: Phasen mitlöschen, sonst tauchen sie nach Rück-Umstellung wieder auf. RecipePhase::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'])) { $this->model->packagings()->detach(); return true; } $ids = $data['pp_packaging_item_id']; $quantities = $data['pp_quantity'] ?? []; $syncData = []; foreach ($ids as $index => $packagingItemId) { $pid = (int) $packagingItemId; if ($pid <= 0) { continue; } $qtyRaw = $quantities[$index] ?? null; $qty = $this->parseNullableDecimal($qtyRaw); if ($qty === null || $qty <= 0) { $qty = 1.0; } $syncData[$pid] = [ 'quantity' => $qty, 'pos' => (int) $index, ]; } $this->model->packagings()->sync($syncData); return true; } public function updateIngredients(array $data = []): bool { if (! array_key_exists('product_inci_sync_sent', $data)) { if (isset($data['product_ingredients']) && is_array($data['product_ingredients'])) { $ids = array_values(array_unique(array_filter(array_map('intval', $data['product_ingredients']), fn (int $id) => $id > 0))); $defaults = Ingredient::whereIn('id', $ids)->pluck('default_factor', 'id'); // Audit N-14: bestehende Werte sichern — der Neuaufbau setzte // gram bisher pauschal auf null (stiller Datenverlust). $existing = ProductIngredient::where('product_id', $this->model->id) ->where('recipe_type', 'product') ->get() ->keyBy('ingredient_id'); ProductIngredient::where('product_id', $this->model->id) ->where('recipe_type', 'product') ->delete(); foreach ($ids as $index => $ingredientId) { $prev = $existing[$ingredientId] ?? null; ProductIngredient::create([ 'product_id' => $this->model->id, 'ingredient_id' => $ingredientId, 'pos' => $index, 'gram' => $prev?->gram, 'factor' => $prev?->factor ?? ($defaults[$ingredientId] ?? 1.10), 'recipe_type' => 'product', ]); } } return true; } if (isset($data['pi_ingredient_id']) && is_array($data['pi_ingredient_id'])) { $ids = $data['pi_ingredient_id']; $grams = $data['pi_gram'] ?? []; $factors = $data['pi_factor'] ?? []; ProductIngredient::where('product_id', $this->model->id) ->where('recipe_type', 'product') ->delete(); foreach ($ids as $index => $ingredientId) { $ingredientId = (int) $ingredientId; if ($ingredientId <= 0) { continue; } ProductIngredient::create([ 'product_id' => $this->model->id, 'ingredient_id' => $ingredientId, 'pos' => $index, 'gram' => $this->parseNullableDecimal($grams[$index] ?? null), 'factor' => $this->parseFactor($factors[$index] ?? null, $ingredientId), 'recipe_type' => 'product', ]); } return true; } ProductIngredient::where('product_id', $this->model->id) ->where('recipe_type', 'product') ->delete(); return true; } public function updateManufacturerIngredients(array $data = []): bool { if (! array_key_exists('manufacturer_inci_sync_sent', $data)) { return true; } // Audit H-4: Delete-then-Insert atomar — ein Fehler beim Neuaufbau darf // die bestehende Rezeptur nicht vernichten. return \DB::transaction(function () use ($data): bool { // R-01: bisherige Rohstoff→Phase-Zuordnung sichern (für die Phasenzuordnung ohne IDs). $previousRowsByPhase = ProductIngredient::where('product_id', $this->model->id) ->where('recipe_type', 'manufacturer') ->get(['ingredient_id', 'recipe_phase_id']) ->groupBy(fn ($r) => (int) ($r->recipe_phase_id ?? 0)) ->map(fn ($rows) => $rows->pluck('ingredient_id')->map(fn ($id) => (int) $id)->unique()->values()->all()) ->all(); ProductIngredient::where('product_id', $this->model->id) ->where('recipe_type', 'manufacturer') ->delete(); // Audit H-5: Phasen auch ohne Rohstoffzeilen persistieren — vorher // gingen Namen/Notizen verloren, wenn (noch) keine Zeile existierte. // N-02 (Nachprüfung 08.09.2026): bestehende Phasen aktualisieren statt // löschen/neu anlegen — Produktions-Verbrauchszeilen referenzieren die Phase // (production_ingredients.recipe_phase_id, nullOnDelete) und verlören sonst // bei jedem Artikelspeichern ihre Zuordnung. $phaseIds = $this->persistRecipePhases($data, $previousRowsByPhase); if (! isset($data['mfg_ingredient_id']) || ! is_array($data['mfg_ingredient_id'])) { return true; } $ids = $data['mfg_ingredient_id']; $grams = $data['mfg_gram'] ?? []; $factors = $data['mfg_factor'] ?? []; $rowPhases = $data['mfg_row_phase'] ?? []; // Audit H-4: unbekannte Rohstoff-IDs vorab prüfen statt am FK zu scheitern. $requestedIds = array_values(array_unique(array_filter(array_map('intval', $ids), fn (int $id) => $id > 0))); $knownIds = Ingredient::query()->whereIn('id', $requestedIds)->pluck('id')->map(fn ($id) => (int) $id)->all(); if ($missing = array_diff($requestedIds, $knownIds)) { throw ValidationException::withMessages([ 'mfg_ingredient_id' => __('Unbekannter Rohstoff in der Hersteller-Rezeptur (ID :ids). Bitte Formular neu laden.', [ 'ids' => implode(', ', $missing), ]), ]); } // N-02: Derselbe Rohstoff darf je Phase nur einmal vorkommen (der Picker verhindert // das; Datenbestand/Direktaufrufe müssen es ebenfalls einhalten, sonst ist die // Zeilenidentität „Rohstoff + Phase“ im Produktionsformular nicht eindeutig). $seen = []; foreach ($ids as $index => $ingredientId) { $ingredientId = (int) $ingredientId; if ($ingredientId <= 0) { continue; } $phaseKey = $ingredientId.'|'.(int) ($rowPhases[$index] ?? 0); if (isset($seen[$phaseKey])) { throw ValidationException::withMessages([ 'mfg_ingredient_id' => __('Rohstoff-ID :id kommt in derselben Phase mehrfach vor — bitte die Mengen in einer Zeile zusammenfassen.', ['id' => $ingredientId]), ]); } $seen[$phaseKey] = true; ProductIngredient::create([ 'product_id' => $this->model->id, 'ingredient_id' => $ingredientId, 'pos' => $index, 'gram' => $this->parseNullableDecimal($grams[$index] ?? null), 'factor' => $this->parseFactor($factors[$index] ?? null, $ingredientId), 'recipe_type' => 'manufacturer', 'recipe_phase_id' => $phaseIds[(int) ($rowPhases[$index] ?? 0)] ?? null, ]); } return true; }); } /** * Legt/aktualisiert die Rezeptur-Phasen (AP-23) und liefert Block-Index => Phase-ID. * Ein einzelner Block ohne Namen und Notiz bleibt „phasenlos" (Altverhalten, * Zeilen behalten recipe_phase_id NULL). * * N-02/R-01: Bestehende Phasen werden aktualisiert statt neu angelegt, damit * Produktions-Verbrauchszeilen (recipe_phase_id) ihre Zuordnung behalten. * Zuordnung Formularblock → bestehende Phase: * 1. übermittelte Phasen-ID (mfg_phase_id[], Formular) — geprüft gegen Produkt/Typ, * 2. gleicher (effektiver) Name, * 3. gemeinsame Rohstoffe (Block enthält Rohstoffe, die die Phase bisher hatte). * Bewusst **keine** Zuordnung nach Reihenfolge: Ein vorangestellter neuer Block * würde sonst die ID einer umbenannten Phase übernehmen (Gegenprüfung R-01). * Nicht zugeordnete bestehende Phasen werden gelöscht (Verbrauchszeilen: nullOnDelete). * * @param array> $previousRowsByPhase [phase_id => ingredient_ids] vor dem Neuaufbau * @return array */ private function persistRecipePhases(array $data, array $previousRowsByPhase = []): array { $names = array_values($data['mfg_phase_name'] ?? []); $notes = array_values($data['mfg_phase_note'] ?? []); $submittedIds = array_values($data['mfg_phase_id'] ?? []); $implicitSingle = count($names) <= 1 && trim((string) ($names[0] ?? '')) === '' && trim((string) ($notes[0] ?? '')) === ''; $existing = RecipePhase::query() ->where('product_id', $this->model->id) ->where('recipe_type', 'manufacturer') ->orderBy('pos')->orderBy('id') ->get(); if ($implicitSingle) { $existing->each->delete(); return []; } $blocks = []; foreach ($names as $index => $name) { $name = trim((string) $name); $note = trim((string) ($notes[$index] ?? '')); $blocks[$index] = [ // Audit H-4: Spaltenlänge varchar(100) absichern (strict mode). 'name' => $name !== '' ? mb_substr($name, 0, 100) : $this->autoPhaseName($index), 'note' => $note !== '' ? $note : null, ]; } // Rohstoffe je Formularblock (für Zuordnung 3). $rowsByBlock = []; foreach (($data['mfg_ingredient_id'] ?? []) as $rowIndex => $ingredientId) { $blockIndex = (int) (($data['mfg_row_phase'] ?? [])[$rowIndex] ?? 0); if ((int) $ingredientId > 0) { $rowsByBlock[$blockIndex][] = (int) $ingredientId; } } $unmatched = $existing->keyBy('id'); $assigned = []; // 1. übermittelte IDs foreach ($blocks as $index => $block) { $id = (int) ($submittedIds[$index] ?? 0); if ($id > 0 && $unmatched->has($id)) { $assigned[$index] = $unmatched->get($id); $unmatched->forget($id); } } // 2. gleicher Name foreach ($blocks as $index => $block) { if (isset($assigned[$index])) { continue; } $match = $unmatched->first(fn (RecipePhase $p) => $p->name === $block['name']); if ($match !== null) { $assigned[$index] = $match; $unmatched->forget($match->id); } } // 3. gemeinsame Rohstoffe (größte Überschneidung gewinnt) foreach ($blocks as $index => $block) { if (isset($assigned[$index]) || empty($rowsByBlock[$index])) { continue; } $best = null; $bestOverlap = 0; foreach ($unmatched as $phase) { $overlap = count(array_intersect($rowsByBlock[$index], $previousRowsByPhase[(int) $phase->id] ?? [])); if ($overlap > $bestOverlap) { $best = $phase; $bestOverlap = $overlap; } } if ($best !== null) { $assigned[$index] = $best; $unmatched->forget($best->id); } } $unmatched->each->delete(); $phaseIds = []; foreach ($blocks as $index => $block) { $attributes = $block + ['pos' => $index]; if (isset($assigned[$index])) { $assigned[$index]->fill($attributes)->save(); $phaseIds[$index] = (int) $assigned[$index]->id; } else { $phase = RecipePhase::create($attributes + ['product_id' => $this->model->id, 'recipe_type' => 'manufacturer']); $phaseIds[$index] = (int) $phase->id; } } return $phaseIds; } /** * Auto-Name für unbenannte Phasen: A…Z, danach AA, AB … (Audit N-12 — * chr(65+index) lief nach „Z" in Sonderzeichen). */ private function autoPhaseName(int $index): string { $label = ''; $n = $index; do { $label = chr(65 + ($n % 26)).$label; $n = intdiv($n, 26) - 1; } while ($n >= 0); return 'Phase '.$label; } private function parseNullableDecimal(mixed $value): ?float { if ($value === null || $value === '') { return null; } if (is_numeric($value)) { return (float) $value; } $normalized = reFormatWawiNumber((string) $value); return $normalized !== null && $normalized !== '' ? (float) $normalized : null; } private function parseFactor(mixed $value, int $ingredientId): float { if ($value === null || $value === '') { $default = Ingredient::whereKey($ingredientId)->value('default_factor'); return $default !== null ? (float) $default : 1.10; } if (is_numeric($value)) { return (float) $value; } $normalized = reFormatWawiNumber((string) $value); return $normalized !== null && $normalized !== '' ? (float) $normalized : 1.10; } public function updateCategories($data = []) { foreach ($this->model->categories as $category) { if (($pos = array_search($category->category_id, $data)) !== false) { unset($data[$pos]); } else { $category->delete(); } } // set attr if (is_array($data)) { foreach ($data as $id) { ProductCategory::create([ 'product_id' => $this->model->id, 'category_id' => $id, ]); } } return true; } public function updateAttributes($data = []) { foreach ($this->model->attributes as $attribute) { if (($pos = array_search($attribute->attribute_id, $data)) !== false) { unset($data[$pos]); } else { $attribute->delete(); } } // set attr if (is_array($data)) { foreach ($data as $id) { $attribute = Attribute::findOrFail($id); ProductAttribute::create([ 'product_id' => $this->model->id, 'type_id' => $attribute->attribute_type_id, 'attribute_id' => $attribute->id, ]); } } return true; } public function updateWLVariants($data = []) { foreach ($this->model->attribute_variants as $variant) { if (($pos = array_search($variant->attribute_id, $data)) !== false) { unset($data[$pos]); } else { $variant->delete(); } } // set attr if (is_array($data)) { foreach ($data as $id) { $attribute = Attribute::findOrFail($id); ProductAttribute::create([ 'product_id' => $this->model->id, 'type_id' => $attribute->attribute_type_id, 'attribute_id' => $attribute->id, ]); } } return true; } public function updateWLImageAttributs($attributes = [], $variants = []) { // abgleich der attributes gegen die variants foreach ($attributes as $image => $value) { foreach ($value as $k => $val) { if (! is_array($variants) || ! in_array($val, $variants)) { unset($attributes[$image][$k]); } } } foreach ($this->model->whitelabel_images as $image) { $image->update([ 'attributes' => isset($attributes[$image->id]) ? $attributes[$image->id] : null, ]); } return true; } public function updateCountryPrices($data) { if (! isset($data['country_prices']) || ! is_array($data['country_prices'])) { return false; } foreach ($data['country_prices'] as $k => $country_id) { $cp = CountryPrice::updateOrCreate([ 'country_id' => $country_id, 'product_id' => $this->model->id, ], [ 'c_price' => isset($data['c_price'][$country_id]) ? reFormatNumber($data['c_price'][$country_id]) : null, 'c_tax' => isset($data['c_tax'][$country_id]) ? reFormatNumber($data['c_tax'][$country_id]) : null, 'c_price_old' => isset($data['c_price_old'][$country_id]) ? reFormatNumber($data['c_price_old'][$country_id]) : null, 'c_currency' => isset($data['c_currency'][$country_id]) ? reFormatNumber($data['c_currency'][$country_id]) : null, ]); } return true; } public function copy($model) { $this->model = $model->replicate(); $this->model->name = 'Kopie: '.$this->model->name; $this->model->wp_number = null; $this->model->save(); foreach ($model->categories as $category) { ProductCategory::create([ 'product_id' => $this->model->id, 'category_id' => $category->category_id, ]); } foreach ($model->attributes as $attribute) { ProductAttribute::create([ 'product_id' => $this->model->id, 'type_id' => $attribute->type_id, 'attribute_id' => $attribute->attribute_id, ]); } foreach ($model->attribute_variants as $variant) { ProductAttribute::create([ 'product_id' => $this->model->id, 'type_id' => $variant->type_id, 'attribute_id' => $variant->attribute_id, ]); } foreach ($model->p_ingredients()->orderByPivot('pos')->get() as $ing) { ProductIngredient::create([ 'product_id' => $this->model->id, 'ingredient_id' => $ing->id, 'pos' => (int) ($ing->pivot->pos ?? 0), 'gram' => $ing->pivot->gram, 'factor' => $ing->pivot->factor !== null ? (float) $ing->pivot->factor : 1.10, 'recipe_type' => 'product', ]); } // Phasen (AP-23) mitkopieren und Hersteller-Zeilen der neuen Phase zuordnen $phaseMap = []; foreach ($model->recipePhases()->get() as $phase) { $newPhase = RecipePhase::create([ 'product_id' => $this->model->id, 'recipe_type' => $phase->recipe_type, 'name' => $phase->name, 'note' => $phase->note, 'pos' => $phase->pos, ]); $phaseMap[$phase->id] = $newPhase->id; } foreach ($model->manufacturer_ingredients()->orderByPivot('pos')->get() as $ing) { ProductIngredient::create([ 'product_id' => $this->model->id, 'ingredient_id' => $ing->id, 'pos' => (int) ($ing->pivot->pos ?? 0), 'gram' => $ing->pivot->gram, 'factor' => $ing->pivot->factor !== null ? (float) $ing->pivot->factor : 1.10, 'recipe_type' => 'manufacturer', 'recipe_phase_id' => $phaseMap[$ing->pivot->recipe_phase_id] ?? null, ]); } $packSync = []; foreach ($model->packagings()->orderByPivot('pos')->get() as $pack) { $packSync[$pack->id] = [ 'quantity' => $pack->pivot->quantity !== null ? (float) $pack->pivot->quantity : 1.0, 'pos' => (int) ($pack->pivot->pos ?? 0), ]; } $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, 'product_id' => $this->model->id, 'c_price' => $cp->c_price, 'c_tax' => $cp->c_tax, 'c_price_old' => $cp->c_price_old, 'c_currency' => $cp->c_currency, ]); } $this->copyImages($model->images, 'product'); $this->copyImages($model->whitelabel_images, 'wllogo'); return $this->model; } /** * @param Collection $images */ protected function copyImages($images, string $type): void { foreach ($images as $image) { $name = Slim::sanitizeFileName($image->original_name); $name = uniqid().'_'.$name; $sourcePath = 'images/product/'.$image->product_id.'/'.$image->filename; $targetPath = 'images/product/'.$this->model->id.'/'.$name; if (\Storage::disk('public')->exists($sourcePath)) { \Storage::disk('public')->copy($sourcePath, $targetPath); } ProductImage::create([ 'product_id' => $this->model->id, 'type' => $image->type, 'filename' => $name, 'original_name' => $image->original_name, 'ext' => $image->ext, 'mine' => $image->mine, 'size' => $image->size, 'pos' => $image->pos, 'active' => $image->active ?? true, 'attributes' => $image->attributes, ]); } } public function delete() {} }