*/ private array $config = []; private int $familyId; private int $generalGroupId; private string $channelCode = 'default'; private string $locale = 'en'; /** @var array */ private array $channelIds = []; /** @var array */ private array $channelCodes = []; /** @var array */ private array $localeCodes = []; /** @var array */ private array $inventorySourceIds = []; /** @var array */ private array $familyAttributes = []; /** @var array */ private array $touchedProductIds = []; public function __construct(protected AttributeRepository $attributeRepository) { parent::__construct(); } public function handle(): int { $connection = (string) $this->option('connection'); $batchSize = max(1, (int) $this->option('batch-size')); $resetProgress = (bool) $this->option('reset-progress'); $dryRun = (bool) $this->option('dry-run'); $sku = trim((string) $this->option('sku')); $limit = $this->option('limit') !== null && $this->option('limit') !== '' ? max(1, (int) $this->option('limit')) : null; $this->config = array_merge( Magento1ProductReader::defaultConfig(), (array) config('asteria.products', []) ); DB::disableQueryLog(); try { DB::connection($connection)->getPdo(); } catch (\Throwable $e) { $this->error("Cannot connect to Asteria DB (connection='{$connection}'): ".$e->getMessage()); return self::FAILURE; } foreach (['catalog_product_entity', 'eav_attribute'] as $table) { if (! Schema::connection($connection)->hasTable($table)) { $this->error("Asteria table '{$table}' is missing on connection '{$connection}'."); return self::FAILURE; } } if (! Schema::hasColumn('products', 'migrated_from_asteria_id')) { $this->error('products.migrated_from_asteria_id is missing. Run php artisan migrate.'); return self::FAILURE; } $family = $this->resolveFamily(); if (! $family) { $this->error('No Bagisto attribute family found. Seed attributes first.'); return self::FAILURE; } $this->familyId = (int) $family->id; $groupId = DB::table('attribute_groups') ->where('attribute_family_id', $this->familyId) ->where('code', 'general') ->value('id') ?? DB::table('attribute_groups') ->where('attribute_family_id', $this->familyId) ->orderBy('position') ->value('id'); if (! $groupId) { $this->error("Attribute family '{$family->code}' has no attribute group."); return self::FAILURE; } $this->generalGroupId = (int) $groupId; if (! $this->resolveChannelsLocalesAndSources()) { return self::FAILURE; } $reader = new Magento1ProductReader($connection, $this->config); if (! $this->option('products-only')) { $attributeResult = $this->syncAttributes($reader, $dryRun); $this->info("Attributes: created={$attributeResult['created']}, skipped={$attributeResult['skipped']}."); } if ($this->option('attributes-only')) { return self::SUCCESS; } $this->loadFamilyAttributes($family); $lastId = ($resetProgress || $sku !== '') ? 0 : (int) Cache::get(self::PROGRESS_KEY, 0); if ($resetProgress) { Cache::forget(self::PROGRESS_KEY); } if ($lastId > 0) { $this->line("Resuming from Asteria entity_id > {$lastId} (use --reset-progress to restart)."); } $created = 0; $updated = 0; $skipped = 0; $batchNumber = 0; $remaining = $limit; $this->info($dryRun ? '[DRY RUN] Scanning Magento products…' : 'Migrating Magento products…'); do { $started = microtime(true); $take = $remaining !== null ? min($batchSize, $remaining) : $batchSize; $products = $reader->fetchProducts($lastId, $take, $sku !== '' ? $sku : null); if ($products->isEmpty()) { break; } $batchNumber++; $lastId = (int) $products->max('entity_id'); if ($dryRun) { $created += $products->count(); $this->line(sprintf( ' Batch #%d: %d products (last entity_id=%d) [skipped – dry-run] (%.1fs)', $batchNumber, $products->count(), $lastId, microtime(true) - $started )); } else { $result = $this->persistBatch($products); if ($sku === '') { Cache::put(self::PROGRESS_KEY, $lastId, now()->addDays(30)); } $created += $result['created']; $updated += $result['updated']; $skipped += $result['skipped']; $this->line(sprintf( ' Batch #%d: created=%d updated=%d skipped=%d (last entity_id=%d) (%.1fs)', $batchNumber, $result['created'], $result['updated'], $result['skipped'], $lastId, microtime(true) - $started )); Log::info('MigrateAsteriaProducts: batch '.$batchNumber.', last_id='.$lastId); } if ($remaining !== null) { $remaining -= $products->count(); } } while ( $sku === '' && ($remaining === null || $remaining > 0) && $products->count() === $take ); if (! $dryRun && ! $this->option('no-index')) { $this->reindexProducts(); } $this->newLine(); $this->info("Done. Batches: {$batchNumber}, created: {$created}, updated: {$updated}, skipped: {$skipped}."); return self::SUCCESS; } /** * @return array{created: int, skipped: int} */ private function syncAttributes(Magento1ProductReader $reader, bool $dryRun): array { $definitions = $reader->fetchAttributeDefinitions(); $created = 0; $skipped = 0; $this->info('Syncing '.$definitions->count().' Magento attributes…'); foreach ($definitions as $row) { $code = trim((string) ($row['code'] ?? '')); if ($code === '') { continue; } $existing = Attribute::query()->where('code', $code)->first(); if ($existing) { if (! $dryRun) { $this->ensureAttributeInFamily((int) $existing->id); $this->ensureAttributeOptions($existing, $row['options'] ?? []); } $skipped++; continue; } if ($dryRun) { $created++; continue; } $type = (string) ($row['type'] ?? 'text'); $name = (string) (($row['name'] ?? '') !== '' ? $row['name'] : $code); $data = array_merge([ 'code' => $code, 'admin_name' => $name, 'type' => $type, 'is_required' => (int) ($row['is_required'] ?? 0), 'is_filterable' => (int) ($row['is_filterable'] ?? 0), 'is_comparable' => (int) ($row['is_comparable'] ?? 0), 'is_configurable' => (int) ($row['is_configurable'] ?? 0), 'position' => (int) ($row['position'] ?? 0), 'is_user_defined' => 1, 'value_per_locale' => in_array($type, ['text', 'textarea'], true) ? 1 : 0, 'value_per_channel' => 0, ], $this->translations('name', $name)); if (in_array($type, ['select', 'multiselect', 'checkbox'], true) && ! empty($row['options'])) { $options = []; foreach (array_values($row['options']) as $i => $option) { $label = trim((string) ($option['label'] ?? '')); if ($label === '') { continue; } $options[] = array_merge([ 'admin_name' => $label, 'sort_order' => (int) ($option['sort_order'] ?? $i), ], $this->translations('label', $label)); } if ($options !== []) { $data['options'] = $options; } } $attribute = $this->attributeRepository->create($data); $this->ensureAttributeInFamily((int) $attribute->id); $created++; } return ['created' => $created, 'skipped' => $skipped]; } /** * @param Collection> $products * @return array{created: int, updated: int, skipped: int} */ private function persistBatch(Collection $products): array { $created = 0; $updated = 0; $skipped = 0; $warnAt = (int) ($this->config['max_variants_warn'] ?? 500); foreach ($products as $row) { $sku = trim((string) ($row['sku'] ?? '')); if ($sku === '') { $skipped++; continue; } $variantCount = is_array($row['variants'] ?? null) ? count($row['variants']) : 0; if ($variantCount > $warnAt) { $this->warn("SKU {$sku} will generate {$variantCount} variants (threshold {$warnAt})."); } try { $wasNew = $this->persistProduct($row); } catch (\Throwable $e) { $skipped++; $this->error('Product '.$sku.' failed: '.$e->getMessage()); Log::error('MigrateAsteriaProducts: '.$sku.' '.$e->getMessage(), ['exception' => $e]); continue; } if ($wasNew) { $created++; } else { $updated++; } } return compact('created', 'updated', 'skipped'); } /** * @param array $row */ private function persistProduct(array $row): bool { $sku = trim((string) $row['sku']); $asteriaId = (int) $row['entity_id']; $product = Product::query()->where('migrated_from_asteria_id', $asteriaId)->first() ?? Product::query()->firstOrNew(['sku' => $sku]); $wasNew = ! $product->exists; $product->sku = $sku; $product->type = 'flexible_variant'; $product->attribute_family_id = $this->familyId; $product->migrated_from_asteria_id = $asteriaId; $product->save(); if ($wasNew && ! empty($row['created_at'])) { DB::table('products')->where('id', $product->id)->update([ 'created_at' => $row['created_at'], ]); } $this->touchedProductIds[] = (int) $product->id; $this->saveChannels($product); $this->saveAttributeValues($product, $row); $this->saveInventory($product, $row); $this->saveImages($product, $row); $this->saveCategories($product, is_array($row['categories'] ?? null) ? $row['categories'] : []); DB::transaction(function () use ($product, $row) { $this->saveOptionsAndVariants($product, $row); }); return $wasNew; } /** * @param array $row */ private function saveAttributeValues(Product $product, array $row): void { foreach ([ 'visible_individually' => 1, 'manage_stock' => 1, 'status' => 1, 'guest_checkout' => 1, ] as $code => $default) { if (! array_key_exists($code, $row) || $row[$code] === null || $row[$code] === '') { $row[$code] = $default; } } $values = []; foreach ($this->familyAttributes as $code => $attribute) { if (! array_key_exists($code, $row)) { continue; } $rawValue = $row[$code]; if ($rawValue === null || $rawValue === '') { continue; } $columnValue = $this->castAttributeValue($attribute, $rawValue); if ($columnValue === null) { continue; } $channelCodes = $attribute->value_per_channel ? $this->channelCodes : [null]; $localeCodes = $attribute->value_per_locale ? $this->localeCodes : [null]; foreach ($channelCodes as $channelCode) { foreach ($localeCodes as $localeCode) { $valueRow = array_fill_keys(array_values($attribute->attributeTypeFields), null); $valueRow['json_value'] = null; $valueRow[$attribute->column_name] = $columnValue; $valueRow['attribute_id'] = $attribute->id; $valueRow['product_id'] = $product->id; $valueRow['channel'] = $channelCode; $valueRow['locale'] = $localeCode; $valueRow['unique_id'] = implode('|', array_filter([ $valueRow['channel'], $valueRow['locale'], $valueRow['product_id'], $valueRow['attribute_id'], ], fn ($part) => $part !== null && $part !== '')); $values[$valueRow['unique_id']] = $valueRow; } } } if ($values === []) { return; } DB::table('product_attribute_values')->upsert( array_values($values), ['unique_id'], ['text_value', 'boolean_value', 'integer_value', 'float_value', 'datetime_value', 'date_value', 'json_value'] ); } /** * @param array $row */ private function saveInventory(Product $product, array $row): void { $qty = $row['qty'] ?? 0; if ($qty === null || (is_string($qty) && trim($qty) === '')) { $qty = 0; } foreach ($this->inventorySourceIds as $sourceId) { DB::table('product_inventories')->updateOrInsert( [ 'product_id' => $product->id, 'inventory_source_id' => $sourceId, 'vendor_id' => 0, ], ['qty' => (int) $qty] ); } } /** * @param array $row */ private function saveImages(Product $product, array $row): void { $urls = []; if (! empty($row['base_image'])) { $urls[] = trim((string) $row['base_image']); } if (! empty($row['additional_images'])) { $additional = $row['additional_images']; if (is_array($additional)) { $parts = $additional; } else { $parts = explode('|', (string) $additional); } foreach ($parts as $url) { $url = trim((string) $url); if ($url !== '') { $urls[] = $url; } } } $urls = array_values(array_unique($urls)); DB::table('product_images')->where('product_id', $product->id)->delete(); if ($urls === []) { return; } $hasBase = Schema::hasColumn('product_images', 'is_base_image'); $hasSmall = Schema::hasColumn('product_images', 'is_small_image'); $hasThumb = Schema::hasColumn('product_images', 'is_thumbnail'); $records = []; foreach ($urls as $position => $url) { $record = [ 'type' => 'images', 'path' => $url, 'product_id' => $product->id, 'position' => $position + 1, ]; if ($hasBase) { $record['is_base_image'] = $position === 0 ? 1 : 0; } if ($hasSmall) { $record['is_small_image'] = $position === 0 ? 1 : 0; } if ($hasThumb) { $record['is_thumbnail'] = $position === 0 ? 1 : 0; } $records[] = $record; } DB::table('product_images')->insert($records); } /** * @param array $names */ private function saveCategories(Product $product, array $names): void { DB::table('product_categories')->where('product_id', $product->id)->delete(); $names = array_values(array_unique(array_filter(array_map('trim', $names)))); if ($names === [] || ! Schema::hasTable('category_translations')) { return; } $query = DB::table('category_translations')->whereIn('name', $names); if (Schema::hasColumn('category_translations', 'locale')) { $query->where('locale', $this->locale); } $ids = $query->pluck('category_id')->map(fn ($id) => (int) $id)->unique()->values()->all(); if ($ids === []) { return; } $hasPosition = Schema::hasColumn('product_categories', 'position'); $rows = []; foreach ($ids as $position => $categoryId) { $row = [ 'product_id' => $product->id, 'category_id' => $categoryId, ]; if ($hasPosition) { $row['position'] = $position; } $rows[] = $row; } DB::table('product_categories')->insert($rows); } /** * @param array $row */ private function saveOptionsAndVariants(Product $product, array $row): void { $superCodes = array_values(array_filter(array_map( 'trim', explode(',', (string) ($row['super_attributes'] ?? '')) ))); $this->clearVariantData($product); if ($superCodes === []) { return; } $optionsMeta = $row['options'] ?? []; if (is_string($optionsMeta)) { $optionsMeta = json_decode($optionsMeta, true) ?: []; } $variants = $row['variants'] ?? []; if (is_string($variants)) { $variants = json_decode($variants, true) ?: []; } $optionsByCode = []; foreach ($optionsMeta as $index => $meta) { if (! is_array($meta)) { continue; } $title = (string) ($meta['title'] ?? ''); $optionsByCode[$this->sanitizeAttributeCode($title)] = $meta; $optionsByCode[(string) $index] = $meta; } $labelToValueId = []; $syncOptions = []; foreach ($superCodes as $index => $code) { $meta = $optionsByCode[$code] ?? $optionsMeta[$index] ?? []; $optionLabel = (string) ($meta['title'] ?? Str::title(str_replace('_', ' ', $code))); $option = ProductOption::query()->firstOrCreate( ['code' => $code], ['label' => $optionLabel, 'type' => 'select', 'position' => $index] ); $syncOptions[$option->id] = ['position' => $index, 'is_required' => true]; $existing = ProductOptionValue::query() ->where('product_option_id', $option->id) ->get() ->keyBy('label'); foreach ($existing as $label => $value) { $labelToValueId[$index][$label] = (int) $value->id; } $orderedLabels = []; $valueSku = []; foreach (($meta['values'] ?? []) as $valueMeta) { $label = (string) ($valueMeta['title'] ?? ''); if ($label === '' || array_key_exists($label, $valueSku)) { continue; } $orderedLabels[] = $label; $valueSku[$label] = (string) ($valueMeta['sku'] ?? ''); } foreach ($variants as $variant) { $label = $this->variantLabel($variant, $index, $code, $optionLabel); if ($label !== null && $label !== '' && ! array_key_exists($label, $valueSku)) { $orderedLabels[] = $label; $valueSku[$label] = ''; } } $nextPosition = (int) ProductOptionValue::query() ->where('product_option_id', $option->id) ->max('position'); foreach ($orderedLabels as $label) { if (isset($labelToValueId[$index][$label])) { continue; } $valueCode = $valueSku[$label] !== '' ? $valueSku[$label] : Str::slug($label); if ($valueCode === '') { $valueCode = 'value-'.($nextPosition + 1); } $value = ProductOptionValue::query()->create([ 'product_option_id' => $option->id, 'label' => $label, 'code' => $valueCode, 'position' => ++$nextPosition, ]); $labelToValueId[$index][$label] = (int) $value->id; } } $product->options()->sync($syncOptions); $this->saveVariants($product, $row, $superCodes, $variants, $labelToValueId, $optionsByCode); } /** * @param array $row * @param array $superCodes * @param array> $variants * @param array> $labelToValueId * @param array> $optionsByCode */ private function saveVariants( Product $product, array $row, array $superCodes, array $variants, array $labelToValueId, array $optionsByCode ): void { if ($variants === []) { return; } $parentName = (string) ($row['name'] ?? ''); $now = now(); $variantRecords = []; foreach ($variants as $sortOrder => $variant) { $sku = trim((string) ($variant['sku'] ?? '')); if ($sku === '') { continue; } $variantRecords[] = [ 'product_id' => $product->id, 'sku' => $sku, 'name' => $parentName !== '' ? $parentName : null, 'price' => (float) ($variant['price'] ?? 0), 'quantity' => (int) ($variant['qty'] ?? 0), 'status' => 1, 'sort_order' => $sortOrder, 'created_at' => $now, 'updated_at' => $now, ]; } if ($variantRecords === []) { return; } foreach (array_chunk($variantRecords, 500) as $chunk) { ProductVariant::insert($chunk); } $variantIdBySku = ProductVariant::query() ->where('product_id', $product->id) ->pluck('id', 'sku') ->all(); $pivotRecords = []; foreach ($variants as $variant) { $sku = trim((string) ($variant['sku'] ?? '')); $variantId = $variantIdBySku[$sku] ?? null; if (! $variantId) { continue; } foreach ($superCodes as $index => $code) { $optionLabel = (string) (($optionsByCode[$code]['title'] ?? '') ?: $code); $label = $this->variantLabel($variant, $index, $code, $optionLabel); if ($label === null || $label === '') { continue; } $valueId = $labelToValueId[$index][$label] ?? null; if (! $valueId) { continue; } $pivotRecords[] = [ 'product_variant_id' => $variantId, 'product_option_value_id' => $valueId, 'created_at' => $now, 'updated_at' => $now, ]; } } foreach (array_chunk($pivotRecords, 1000) as $chunk) { DB::table('product_variant_option_values')->insert($chunk); } } /** * @param array $variant */ private function variantLabel(array $variant, int $index, string $code, string $title): ?string { foreach ([$code, $title, (string) $index] as $key) { if ($key !== '' && array_key_exists($key, $variant) && $variant[$key] !== null && $variant[$key] !== '') { return (string) $variant[$key]; } } return null; } private function clearVariantData(Product $product): void { $variantIds = ProductVariant::withTrashed() ->where('product_id', $product->id) ->pluck('id') ->all(); if ($variantIds !== []) { DB::table('product_variant_option_values') ->whereIn('product_variant_id', $variantIds) ->delete(); if (Schema::hasTable('product_variant_images')) { DB::table('product_variant_images') ->whereIn('product_variant_id', $variantIds) ->delete(); } ProductVariant::withTrashed()->whereIn('id', $variantIds)->forceDelete(); } $product->options()->detach(); } private function reindexProducts(): void { $ids = array_values(array_unique($this->touchedProductIds)); if ($ids === []) { return; } $this->info('Reindexing '.count($ids).' products…'); $priceIndexer = app(Price::class); $flatIndexer = app(Flat::class); $elasticIndexer = $this->elasticEnabled() ? app(ElasticSearch::class) : null; $relations = [ 'attribute_family', 'attribute_values', 'channels', 'price_indices', 'customer_group_prices', 'catalog_rule_prices', 'flexibleVariants', 'flexibleVariants.price_indices', 'flexibleVariants.customer_group_prices', ]; foreach (array_chunk($ids, 20) as $chunk) { $products = Product::query()->with($relations)->whereIn('id', $chunk)->get(); foreach ($products as $product) { try { $priceIndexer->reindexRow($product); $flatIndexer->reindexRow($product); $elasticIndexer?->reindexRow($product); } catch (\Throwable $e) { $this->error('Reindex failed for product '.$product->sku.': '.$e->getMessage()); } } } } private function elasticEnabled(): bool { return core()->getConfigData('catalog.products.search.engine') === 'elastic'; } private function castAttributeValue(Attribute $attribute, mixed $value): mixed { return match ($attribute->type) { 'boolean' => (int) (bool) (is_numeric($value) ? (int) $value : $value), 'price' => (float) $value, 'select' => AttributeOption::query() ->where('attribute_id', $attribute->id) ->where('admin_name', $value) ->value('id'), 'multiselect' => $this->castMultiselectValue((int) $attribute->id, (string) $value), default => $value, }; } private function castMultiselectValue(int $attributeId, string $value): ?string { $ids = []; foreach (array_map('trim', explode(',', $value)) as $label) { if ($label === '') { continue; } $optionId = AttributeOption::query() ->where('attribute_id', $attributeId) ->where('admin_name', $label) ->value('id'); if ($optionId) { $ids[] = $optionId; } } return $ids === [] ? null : implode(',', $ids); } /** * @param array> $options */ private function ensureAttributeOptions(Attribute $attribute, array $options): void { if (! in_array($attribute->type, ['select', 'multiselect', 'checkbox'], true) || $options === []) { return; } $nextSort = (int) AttributeOption::query() ->where('attribute_id', $attribute->id) ->max('sort_order'); foreach (array_values($options) as $i => $option) { $label = trim((string) ($option['label'] ?? '')); if ($label === '') { continue; } $exists = AttributeOption::query() ->where('attribute_id', $attribute->id) ->where('admin_name', $label) ->exists(); if ($exists) { continue; } AttributeOption::query()->create(array_merge([ 'attribute_id' => $attribute->id, 'admin_name' => $label, 'sort_order' => (int) ($option['sort_order'] ?? ($nextSort + $i + 1)), ], $this->translations('label', $label))); } } private function ensureAttributeInFamily(int $attributeId): void { $exists = DB::table('attribute_group_mappings') ->where('attribute_id', $attributeId) ->where('attribute_group_id', $this->generalGroupId) ->exists(); if ($exists) { return; } $position = (int) DB::table('attribute_group_mappings') ->where('attribute_group_id', $this->generalGroupId) ->max('position'); DB::table('attribute_group_mappings')->insert([ 'attribute_id' => $attributeId, 'attribute_group_id' => $this->generalGroupId, 'position' => $position + 1, ]); } private function loadFamilyAttributes(AttributeFamily $family): void { $this->familyAttributes = []; foreach ($family->custom_attributes()->get() as $attribute) { $this->familyAttributes[$attribute->code] = $attribute; } } private function resolveFamily(): ?AttributeFamily { $preferred = (string) ($this->config['attribute_family'] ?? 'wigs'); foreach (array_unique(array_filter([$preferred, 'wigs', 'variant_product', 'default'])) as $code) { $family = AttributeFamily::query()->where('code', $code)->first(); if ($family) { if ($code !== $preferred) { $this->comment("Attribute family '{$preferred}' not found, using '{$code}'."); } return $family; } } return AttributeFamily::query()->first(); } private function resolveChannelsLocalesAndSources(): bool { $codes = $this->configuredCodes('channels', 'channel'); $query = Channel::query()->with(['locales', 'inventory_sources']); if ($codes !== []) { $query->whereIn('code', $codes); } $channels = $query->get(); if ($channels->isEmpty()) { $this->error($codes === [] ? 'No Bagisto channel was found.' : 'Configured channel code(s) not found: '.implode(', ', $codes).'.'); return false; } if ($codes !== [] && $channels->count() !== count($codes)) { $found = $channels->pluck('code')->all(); $this->warn('Unknown channel codes skipped: '.implode(', ', array_diff($codes, $found)).'.'); } $this->channelIds = $channels->pluck('id')->map(fn ($id) => (int) $id)->all(); $this->channelCodes = $channels->pluck('code')->map(fn ($code) => (string) $code)->unique()->values()->all(); $this->channelCode = $this->channelCodes[0]; $localeCodes = $this->configuredCodes('locales'); if ($localeCodes === []) { $localeCodes = $channels ->flatMap(fn (Channel $channel) => $channel->locales->pluck('code')) ->filter() ->map(fn ($code) => (string) $code) ->unique() ->values() ->all(); } if ($localeCodes === []) { $localeCodes = ['en']; } $this->localeCodes = $localeCodes; $this->locale = $this->localeCodes[0]; $sourceIds = $channels ->flatMap(fn (Channel $channel) => $channel->inventory_sources->pluck('id')) ->map(fn ($id) => (int) $id) ->filter() ->unique() ->values() ->all(); if ($sourceIds === []) { $fallback = (int) (DB::table('inventory_sources')->value('id') ?? 0); if ($fallback === 0) { $this->error('No inventory_sources found. Run Bagisto seeders for inventory.'); return false; } $sourceIds = [$fallback]; } $this->inventorySourceIds = $sourceIds; $this->comment('Channels: '.implode(', ', $this->channelCodes)); $this->comment('Locales: '.implode(', ', $this->localeCodes)); return true; } /** * @return array */ private function configuredCodes(string $listKey, ?string $singleKey = null): array { $codes = $this->config[$listKey] ?? []; if (is_string($codes)) { $codes = explode(',', $codes); } $codes = array_values(array_filter(array_map('trim', array_map('strval', (array) $codes)))); if ($codes === [] && $singleKey !== null) { $single = trim((string) ($this->config[$singleKey] ?? '')); if ($single !== '') { $codes = [$single]; } } return $codes; } private function saveChannels(Product $product): void { foreach ($this->channelIds as $channelId) { DB::table('product_channels')->updateOrInsert([ 'product_id' => $product->id, 'channel_id' => $channelId, ]); } } /** * @return array> */ private function translations(string $field, string $value): array { $payload = []; foreach ($this->localeCodes as $locale) { $payload[$locale] = [$field => $value]; } if ($payload === []) { $payload[$this->locale] = [$field => $value]; } return $payload; } private function sanitizeAttributeCode(string $title): string { $code = strtolower($title); $code = (string) preg_replace('/[^a-z0-9]+/', '_', $code); return trim($code, '_'); } }