- products.whitelabel_variant_id, product_images.position/size_cm mit Backfill aus Produktnamen - WhiteLabelResolver: Farbe vom Produkt, Kundenlogo je Position, Fehler statt stillem Rückfall; Altlogik für Produkte ohne Variante - Attribute: Variantenwert-Parsing und Farbname ohne Größe - Produkt- und Kundenformular um Variante, Position und Größe ergänzt - Lieferschein noch nicht umgestellt (WL-03, wartet auf K6/K7) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
852 lines
34 KiB
PHP
852 lines
34 KiB
PHP
<?php
|
|
|
|
namespace App\Repositories;
|
|
|
|
use App\Models\Attribute;
|
|
use App\Models\CountryPrice;
|
|
use App\Models\Ingredient;
|
|
use App\Models\Product;
|
|
use App\Models\ProductAttribute;
|
|
use App\Models\ProductCategory;
|
|
use App\Models\ProductImage;
|
|
use App\Models\ProductIngredient;
|
|
use App\Models\RecipePhase;
|
|
use App\Models\UserWhitelabelProduct;
|
|
use App\Services\Slim;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Database\Eloquent\Collection;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
class ProductRepository extends BaseRepository
|
|
{
|
|
public function __construct(Product $model)
|
|
{
|
|
$this->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;
|
|
// WL-02: verbindliche Tiegelfarbe/Größe (leer => null, Altlogik).
|
|
$data['whitelabel_variant_id'] = isset($data['whitelabel_variant_id']) && $data['whitelabel_variant_id'] !== ''
|
|
? (int) $data['whitelabel_variant_id']
|
|
: null;
|
|
$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'] : []);
|
|
$this->updateWLImagePositions(isset($data['image_wl_position']) ? $data['image_wl_position'] : [], isset($data['image_wl_size']) ? $data['image_wl_size'] : []);
|
|
|
|
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<int, array<int, int>> $previousRowsByPhase [phase_id => ingredient_ids] vor dem Neuaufbau
|
|
* @return array<int, int>
|
|
*/
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* WL-02: Etikettposition und Größe (cm) je Standardetikett.
|
|
*
|
|
* @param array<int, string> $positions Bild-ID => Position
|
|
* @param array<int, string> $sizes Bild-ID => Größe in cm (deutsche Schreibweise erlaubt)
|
|
*/
|
|
public function updateWLImagePositions(array $positions = [], array $sizes = []): bool
|
|
{
|
|
foreach ($this->model->whitelabel_images as $image) {
|
|
$position = $positions[$image->id] ?? null;
|
|
$size = isset($sizes[$image->id]) && trim((string) $sizes[$image->id]) !== '' ? (float) reFormatNumber($sizes[$image->id]) : null;
|
|
$image->update([
|
|
'position' => array_key_exists($position, ProductImage::POSITIONS) ? $position : ($image->position ?: 'deckel'),
|
|
'size_cm' => $size !== null && $size > 0 ? $size : 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, bool $withWhitelabelAssignments = false)
|
|
{
|
|
$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');
|
|
|
|
// Kundenzuordnungen (Partner-Logos) nur auf ausdrückliche Anforderung mitkopieren.
|
|
// Die stille Kopie ohne Zuordnungen war die Ursache für fehlende Logos auf Lieferscheinen (WL-00).
|
|
if ($withWhitelabelAssignments) {
|
|
$this->copyWhitelabelAssignments($model, $this->model);
|
|
}
|
|
|
|
return $this->model;
|
|
}
|
|
|
|
/**
|
|
* Kundenzuordnungen (user_whitelabel_products) samt Logo-Dateien von einem Produkt auf ein anderes übertragen.
|
|
* Vorhandene Kombinationen (user_id, product_id) am Zielprodukt werden übersprungen; wiederholbar ohne Dubletten.
|
|
*
|
|
* @return array{copied: int, skipped: int, users: array<int, int>}
|
|
*/
|
|
public function copyWhitelabelAssignments(Product $source, Product $target, ?int $userId = null): array
|
|
{
|
|
$result = ['copied' => 0, 'skipped' => 0, 'users' => []];
|
|
|
|
$query = UserWhitelabelProduct::query()->where('product_id', $source->id);
|
|
if ($userId !== null) {
|
|
$query->where('user_id', $userId);
|
|
}
|
|
|
|
foreach ($query->orderBy('id')->get() as $assignment) {
|
|
$exists = UserWhitelabelProduct::query()
|
|
->where('product_id', $target->id)
|
|
->where('user_id', $assignment->user_id)
|
|
->exists();
|
|
if ($exists) {
|
|
$result['skipped']++;
|
|
|
|
continue;
|
|
}
|
|
|
|
$copy = UserWhitelabelProduct::create([
|
|
'user_id' => $assignment->user_id,
|
|
'product_id' => $target->id,
|
|
'info' => $assignment->info,
|
|
'attributes' => $assignment->attributes,
|
|
'options' => $assignment->options,
|
|
'active' => $assignment->active,
|
|
]);
|
|
|
|
foreach ($assignment->whitelabel_images as $image) {
|
|
$name = uniqid().'_'.Slim::sanitizeFileName($image->original_name);
|
|
$sourcePath = 'images/user_product/'.$assignment->id.'/'.$image->filename;
|
|
$targetPath = 'images/user_product/'.$copy->id.'/'.$name;
|
|
|
|
if (\Storage::disk('public')->exists($sourcePath)) {
|
|
\Storage::disk('public')->copy($sourcePath, $targetPath);
|
|
}
|
|
|
|
ProductImage::create([
|
|
'user_wl_product_id' => $copy->id,
|
|
'type' => 'uwllogo',
|
|
'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,
|
|
]);
|
|
}
|
|
|
|
$result['copied']++;
|
|
$result['users'][] = $assignment->user_id;
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* @param Collection<int, ProductImage> $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() {}
|
|
}
|